feat: app install flow followup (#15193)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Omar López <zomars@me.com> Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
This commit is contained in:
co-authored by
Joe Au-Yeung
Omar López
Joe Au-Yeung
parent
7d8cb7d6bb
commit
f1b4d7d9e0
@@ -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<boolean>(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 (
|
||||
<InstallAppButtonChild
|
||||
appCategories={categories}
|
||||
userAdminTeams={appDbQuery.data?.userAdminTeams}
|
||||
addAppMutationInput={{ type, variant, slug }}
|
||||
multiInstall
|
||||
concurrentMeetings={concurrentMeetings}
|
||||
paid={paid}
|
||||
dirName={dirName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <InstallAppButtonChild multiInstall paid={paid} {...props} />;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -260,22 +288,13 @@ export const AppPage = ({
|
||||
props = {
|
||||
...props,
|
||||
onClick: () => {
|
||||
mutation.mutate({ type, variant, slug });
|
||||
handleAppInstall();
|
||||
},
|
||||
loading: mutation.isPending,
|
||||
loading: isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<InstallAppButtonChild
|
||||
appCategories={categories}
|
||||
userAdminTeams={appDbQuery.data?.userAdminTeams}
|
||||
addAppMutationInput={{ type, variant, slug }}
|
||||
credentials={appDbQuery.data?.credentials}
|
||||
concurrentMeetings={concurrentMeetings}
|
||||
paid={paid}
|
||||
dirName={dirName}
|
||||
{...props}
|
||||
/>
|
||||
<InstallAppButtonChild credentials={appDbQuery.data?.credentials} paid={paid} {...props} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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<HTMLElement, 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 (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
onClick={_onClick}
|
||||
{...props}
|
||||
disabled={shouldDisableInstallation}
|
||||
color="primary"
|
||||
@@ -89,97 +33,14 @@ export const InstallAppButtonChild = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!userAdminTeams?.length ||
|
||||
!doesAppSupportTeamInstall({ appCategories, concurrentMeetings, isPaid: !!paid })
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
onClick={_onClick}
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
disabled={shouldDisableInstallation}
|
||||
size="base">
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (redirectToAppOnboarding) {
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
disabled={shouldDisableInstallation}
|
||||
onClick={_onClick}
|
||||
color="primary"
|
||||
size="base"
|
||||
{...props}>
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base">
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
className="w-auto"
|
||||
onInteractOutside={(event) => {
|
||||
if (mutation.isPending) event.preventDefault();
|
||||
}}>
|
||||
{mutation.isPending && (
|
||||
<div className="z-1 fixed inset-0 flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuLabel>{t("install_app_on")}</DropdownMenuLabel>
|
||||
{userAdminTeams.map((team) => {
|
||||
const isInstalled =
|
||||
credentials &&
|
||||
credentials.some((credential) =>
|
||||
credential?.teamId ? credential?.teamId === team.id : credential.userId === team.id
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownItem
|
||||
className="flex"
|
||||
type="button"
|
||||
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
|
||||
key={team.id}
|
||||
disabled={isInstalled}
|
||||
CustomStartIcon={
|
||||
<Avatar
|
||||
alt={team.logoUrl || ""}
|
||||
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)} // if no image, use default avatar
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
mutation.mutate(
|
||||
team.isUser ? addAppMutationInput : { ...addAppMutationInput, teamId: team.id }
|
||||
);
|
||||
}}>
|
||||
<p>
|
||||
{t(team.name)} {isInstalled && `(${t("installed")})`}
|
||||
</p>
|
||||
</DropdownItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
disabled={shouldDisableInstallation}
|
||||
color="primary"
|
||||
size="base"
|
||||
{...props}>
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<AccountSelectorProps> = ({
|
||||
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<AccountSelectorProps> = ({
|
||||
}}>
|
||||
<Avatar
|
||||
alt={avatar || ""}
|
||||
imageSrc={avatar || `${CAL_URL}/${avatar}`} // if no image, use default avatar
|
||||
imageSrc={getPlaceholderAvatar(avatar, name)} // if no image, use default avatar
|
||||
size="sm"
|
||||
/>
|
||||
<div className="text-md pt-0.5 font-medium text-gray-500">
|
||||
@@ -60,13 +60,20 @@ export type TeamsProp = (Pick<Team, "id" | "name" | "logoUrl"> & {
|
||||
})[];
|
||||
|
||||
type AccountStepCardProps = {
|
||||
teams: TeamsProp;
|
||||
teams?: TeamsProp;
|
||||
personalAccount: PersonalAccountProps;
|
||||
onSelect: (id?: number) => void;
|
||||
loading: boolean;
|
||||
installableOnTeams: boolean;
|
||||
};
|
||||
|
||||
export const AccountsStepCard: FC<AccountStepCardProps> = ({ teams, personalAccount, onSelect, loading }) => {
|
||||
export const AccountsStepCard: FC<AccountStepCardProps> = ({
|
||||
teams,
|
||||
personalAccount,
|
||||
onSelect,
|
||||
loading,
|
||||
installableOnTeams,
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<StepCard>
|
||||
@@ -80,17 +87,18 @@ export const AccountsStepCard: FC<AccountStepCardProps> = ({ teams, personalAcco
|
||||
onClick={() => onSelect()}
|
||||
loading={loading}
|
||||
/>
|
||||
{teams.map((team) => (
|
||||
<AccountSelector
|
||||
key={team.id}
|
||||
testId={`install-app-button-team${team.id}`}
|
||||
alreadyInstalled={team.alreadyInstalled}
|
||||
avatar={team.logoUrl ?? ""}
|
||||
name={team.name}
|
||||
onClick={() => onSelect(team.id)}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
{installableOnTeams &&
|
||||
teams?.map((team) => (
|
||||
<AccountSelector
|
||||
key={team.id}
|
||||
testId={`install-app-button-team${team.id}`}
|
||||
alreadyInstalled={team.alreadyInstalled}
|
||||
avatar={team.logoUrl ?? ""}
|
||||
name={team.name}
|
||||
onClick={() => onSelect(team.id)}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
|
||||
@@ -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<typeof EventTypeMetaDataSchema>;
|
||||
locations: LocationObject[];
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields>;
|
||||
seatsPerTimeSlot: number | null;
|
||||
};
|
||||
|
||||
type ConfigureStepCardProps = {
|
||||
export type ConfigureStepCardProps = {
|
||||
slug: string;
|
||||
userName: string;
|
||||
categories: AppCategories[];
|
||||
credentialId?: number;
|
||||
loading?: boolean;
|
||||
isConferencing: boolean;
|
||||
formPortalRef: React.RefObject<HTMLDivElement>;
|
||||
eventTypes: TEventType[] | undefined;
|
||||
setConfigureStep: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -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<typeof EventTypeMetaDataSchema>) => void;
|
||||
};
|
||||
|
||||
type EventTypeAppSettingsWrapperProps = Pick<
|
||||
ConfigureStepCardProps,
|
||||
"slug" | "userName" | "categories" | "credentialId"
|
||||
> & {
|
||||
eventType: TEventType;
|
||||
};
|
||||
|
||||
const EventTypeAppSettingsWrapper: FC<EventTypeAppSettingsWrapperProps> = ({
|
||||
slug,
|
||||
eventType,
|
||||
categories,
|
||||
credentialId,
|
||||
}) => {
|
||||
const { getAppDataGetter, getAppDataSetter } = useAppsData();
|
||||
|
||||
useEffect(() => {
|
||||
const appDataSetter = getAppDataSetter(slug as EventTypeAppsList, categories, credentialId);
|
||||
appDataSetter("enabled", true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EventTypeAppSettings
|
||||
slug={slug}
|
||||
eventType={eventType}
|
||||
getAppData={getAppDataGetter(slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(slug as EventTypeAppsList, categories, credentialId)}
|
||||
/>
|
||||
);
|
||||
onSubmit: ({
|
||||
locations,
|
||||
bookingFields,
|
||||
metadata,
|
||||
}: {
|
||||
metadata?: z.infer<typeof EventTypeMetaDataSchema>;
|
||||
bookingFields?: z.infer<typeof eventTypeBookingFields>;
|
||||
locations?: LocationObject[];
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSettingsFormProps>(
|
||||
function EventTypeAppSettingsForm(props, ref) {
|
||||
const { handleDelete, onSubmit, eventType, loading } = props;
|
||||
const { handleDelete, onSubmit, eventType, loading, isConferencing } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
const formMethods = useForm<TFormType>({
|
||||
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<HTMLButtonElement, EventTypeAppSetti
|
||||
form={formMethods}
|
||||
id={`eventtype-${eventType.id}`}
|
||||
handleSubmit={() => {
|
||||
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 });
|
||||
}}>
|
||||
<div>
|
||||
<div className="sm:border-subtle bg-default relative border p-4 dark:bg-black sm:rounded-md">
|
||||
@@ -98,13 +96,17 @@ const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSetti
|
||||
/{eventType.team ? eventType.team.slug : props.userName}/{eventType.slug}
|
||||
</small>
|
||||
</div>
|
||||
<EventTypeAppSettingsWrapper {...props} />
|
||||
{isConferencing ? (
|
||||
<EventTypeConferencingAppSettings {...props} />
|
||||
) : (
|
||||
<EventTypeAppSettingsWrapper {...props} />
|
||||
)}
|
||||
<X
|
||||
data-testid={`remove-event-type-${eventType.id}`}
|
||||
className="absolute right-4 top-4 h-4 w-4 cursor-pointer"
|
||||
onClick={() => !loading && handleDelete()}
|
||||
/>
|
||||
<button type="submit" className="hidden" ref={ref}>
|
||||
<button type="submit" className="hidden" form={`eventtype-${eventType.id}`} ref={ref}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
@@ -148,6 +150,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
|
||||
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<ConfigureStepCardProps> = ({
|
||||
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))
|
||||
);
|
||||
|
||||
@@ -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<EventTypeAppSettingsWrapperProps> = ({
|
||||
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 (
|
||||
<EventTypeAppSettings
|
||||
slug={slug}
|
||||
eventType={eventType}
|
||||
getAppData={getAppDataGetter(slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(slug as EventTypeAppsList, categories, credentialId)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default EventTypeAppSettingsWrapper;
|
||||
@@ -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<TFormType>();
|
||||
|
||||
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 (
|
||||
<div className="mt-2">
|
||||
<Skeleton as={Label} loadingClassName="w-16" htmlFor="locations">
|
||||
{t("location")}
|
||||
</Skeleton>
|
||||
<Locations
|
||||
showAppStoreLink={false}
|
||||
isChildrenManagedEventType={false}
|
||||
isManagedEventType={false}
|
||||
disableLocationProp={false}
|
||||
eventType={eventType as TEventTypeLocation}
|
||||
destinationCalendar={eventType.destinationCalendar}
|
||||
locationOptions={eventType.locationOptions || []}
|
||||
prefillLocation={prefillLocation}
|
||||
team={null}
|
||||
getValues={formMethods.getValues as unknown as UseFormGetValues<LocationFormValues>}
|
||||
setValue={formMethods.setValue as unknown as UseFormSetValue<LocationFormValues>}
|
||||
control={formMethods.control as unknown as Control<LocationFormValues>}
|
||||
formState={formMethods.formState as unknown as FormState<LocationFormValues>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EventTypeConferencingAppSettings = ({ eventType, slug }: { eventType: TEventType; slug: string }) => {
|
||||
const locationsQuery = trpc.viewer.locationOptions.useQuery({});
|
||||
const { t } = useLocale();
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<SkeletonText className="my-2 h-8 w-full" />
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
query={locationsQuery}
|
||||
customLoader={<SkeletonLoader />}
|
||||
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 <LocationsWrapper eventType={updatedEventType} slug={slug} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypeConferencingAppSettings;
|
||||
@@ -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<EventTypeSetupProps, "locationOptions">["locationOptions"]
|
||||
) => {
|
||||
for (const locationOption of locationOptions) {
|
||||
const option = locationOption.options.find((option) => option.value === type);
|
||||
if (option) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getLocationInfo = ({
|
||||
eventType,
|
||||
locationOptions,
|
||||
}: Pick<EventTypeSetupProps, "eventType" | "locationOptions">) => {
|
||||
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<FormValues>();
|
||||
@@ -106,25 +59,13 @@ export const EventSetupTab = (
|
||||
) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
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<HTMLUListElement>();
|
||||
|
||||
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 (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<Input
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
type="text"
|
||||
required
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
{...(shouldLockDisableProps("locations").disabled ? { disabled: true } : {})}
|
||||
className="my-0"
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
disabled={shouldLockDisableProps("locations").disabled}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
|
||||
const defaultInitialLocation = defaultValue || null;
|
||||
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(
|
||||
defaultInitialLocation
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ul ref={animationRef} className="space-y-2">
|
||||
{locationFields.map((field, index) => {
|
||||
const eventLocationType = getEventLocationType(field.type);
|
||||
const defaultLocation = field;
|
||||
|
||||
const option = getLocationFromType(field.type, locationOptions);
|
||||
|
||||
return (
|
||||
<li key={field.id}>
|
||||
<div className="flex w-full items-center">
|
||||
<LocationSelect
|
||||
name={`locations[${index}].type`}
|
||||
placeholder={t("select")}
|
||||
options={locationOptions}
|
||||
isDisabled={shouldLockDisableProps("locations").disabled}
|
||||
defaultValue={option}
|
||||
isSearchable={false}
|
||||
className="block min-w-0 flex-1 rounded-sm text-sm"
|
||||
menuPlacement="auto"
|
||||
onChange={(e: SingleValueLocationOption) => {
|
||||
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) && (
|
||||
<button
|
||||
data-testid={`delete-locations.${index}.type`}
|
||||
className="min-h-9 block h-9 px-2"
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
aria-label={t("remove")}>
|
||||
<div className="h-4 w-4">
|
||||
<Icon name="x" className="border-l-1 hover:text-emphasis text-subtle h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{eventLocationType?.organizerInputType && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="w-full">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<Icon name="corner-down-right" className="h-4 w-4" />
|
||||
</div>
|
||||
<LocationInput
|
||||
defaultValue={
|
||||
defaultLocation
|
||||
? defaultLocation[eventLocationType.defaultValueVariable]
|
||||
: undefined
|
||||
}
|
||||
eventLocationType={eventLocationType}
|
||||
index={index}
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage
|
||||
errors={formMethods.formState.errors?.locations?.[index]}
|
||||
name={eventLocationType.defaultValueVariable}
|
||||
className="text-error my-1 ml-6 text-sm"
|
||||
as="div"
|
||||
id="location-error"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-6">
|
||||
<CheckboxField
|
||||
name={`locations[${index}].displayLocationPublicly`}
|
||||
data-testid="display-location"
|
||||
disabled={shouldLockDisableProps("locations").disabled}
|
||||
defaultChecked={defaultLocation?.displayLocationPublicly}
|
||||
description={t("display_location_label")}
|
||||
onChange={(e) => {
|
||||
const fieldValues = formMethods.getValues("locations")[index];
|
||||
updateLocationField(index, {
|
||||
...fieldValues,
|
||||
displayLocationPublicly: e.target.checked,
|
||||
});
|
||||
}}
|
||||
informationIconText={t("display_location_info_badge")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{(validLocations.length === 0 || showEmptyLocationSelect) && (
|
||||
<div className="flex">
|
||||
<LocationSelect
|
||||
defaultMenuIsOpen={showEmptyLocationSelect}
|
||||
placeholder={t("select")}
|
||||
options={locationOptions}
|
||||
value={selectedNewOption}
|
||||
isDisabled={shouldLockDisableProps("locations").disabled}
|
||||
defaultValue={defaultValue}
|
||||
isSearchable={false}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
menuPlacement="auto"
|
||||
onChange={(e: SingleValueLocationOption) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{validLocations.some(
|
||||
(location) =>
|
||||
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
|
||||
) && (
|
||||
<div className="text-default flex items-center text-sm">
|
||||
<div className="mr-1.5 h-3 w-3">
|
||||
<Icon name="check" className="h-3 w-3" />
|
||||
</div>
|
||||
<p className="text-default text-sm">
|
||||
<Trans i18nKey="event_type_requires_google_calendar">
|
||||
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
|
||||
Connect it
|
||||
<Link className="cursor-pointer text-blue-500 underline" href="/apps/google-calendar">
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
|
||||
<p className="pl-1 text-sm leading-none text-red-600">
|
||||
{t("app_not_connected", { appName: locationDetails.name })}{" "}
|
||||
<a className="underline" href={`${WEBAPP_URL}/apps/${locationDetails.slug}`}>
|
||||
{t("connect_now")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{validLocations.length > 0 && !shouldLockDisableProps("locations").disabled && (
|
||||
// && !isChildrenManagedEventType : Add this to hide add-location button only when location is disabled by Admin
|
||||
<li>
|
||||
<Button
|
||||
data-testid="add-location"
|
||||
StartIcon="plus"
|
||||
color="minimal"
|
||||
disabled={seatsEnabled}
|
||||
tooltip={seatsEnabled ? t("seats_option_doesnt_support_multi_location") : undefined}
|
||||
onClick={() => setShowEmptyLocationSelect(true)}>
|
||||
{t("add_location")}
|
||||
</Button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<p className="text-default mt-2 text-sm">
|
||||
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
|
||||
Can't find the right video app? Visit our
|
||||
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
|
||||
App Store
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const lengthLockedProps = shouldLockDisableProps("length");
|
||||
const descriptionLockedProps = shouldLockDisableProps("description");
|
||||
@@ -625,7 +246,19 @@ export const EventSetupTab = (
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.locations || []}
|
||||
render={() => <Locations />}
|
||||
render={() => (
|
||||
<Locations
|
||||
showAppStoreLink={true}
|
||||
isChildrenManagedEventType={isChildrenManagedEventType}
|
||||
isManagedEventType={isManagedEventType}
|
||||
disableLocationProp={shouldLockDisableProps("locations").disabled}
|
||||
getValues={formMethods.getValues as unknown as UseFormGetValues<LocationFormValues>}
|
||||
setValue={formMethods.setValue as unknown as UseFormSetValue<LocationFormValues>}
|
||||
control={formMethods.control as unknown as Control<LocationFormValues>}
|
||||
formState={formMethods.formState as unknown as FormState<LocationFormValues>}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<EventTypeSetupProps["eventType"], "locations">;
|
||||
export type TLocationOptions = Pick<EventTypeSetupProps, "locationOptions">["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<LocationFormValues>;
|
||||
setValue: UseFormSetValue<LocationFormValues>;
|
||||
control: Control<LocationFormValues>;
|
||||
formState: FormState<LocationFormValues>;
|
||||
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<LocationsProps> = ({
|
||||
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<HTMLUListElement>();
|
||||
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 (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<Input
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
type="text"
|
||||
required
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
{...(disableLocationProp ? { disabled: true } : {})}
|
||||
className="my-0"
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
disabled={disableLocationProp}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
|
||||
const defaultInitialLocation = defaultValue || null;
|
||||
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(
|
||||
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 (
|
||||
<div className="w-full">
|
||||
<ul ref={animationRef} className="space-y-2">
|
||||
{locationFields.map((field, index) => {
|
||||
const eventLocationType = getEventLocationType(field.type);
|
||||
const defaultLocation = field;
|
||||
|
||||
const option = getLocationFromType(field.type, locationOptions);
|
||||
return (
|
||||
<li key={field.id}>
|
||||
<div className="flex w-full items-center">
|
||||
<LocationSelect
|
||||
name={`locations[${index}].type`}
|
||||
placeholder={t("select")}
|
||||
options={locationOptions}
|
||||
isDisabled={disableLocationProp}
|
||||
defaultValue={option}
|
||||
isSearchable={false}
|
||||
className="block min-w-0 flex-1 rounded-sm text-sm"
|
||||
menuPlacement="auto"
|
||||
onChange={(e: SingleValueLocationOption) => {
|
||||
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) && (
|
||||
<button
|
||||
data-testid={`delete-locations.${index}.type`}
|
||||
className="min-h-9 block h-9 px-2"
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
aria-label={t("remove")}>
|
||||
<div className="h-4 w-4">
|
||||
<Icon name="x" className="border-l-1 hover:text-emphasis text-subtle h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{eventLocationType?.organizerInputType && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="w-full">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<Icon name="corner-down-right" className="h-4 w-4" />
|
||||
</div>
|
||||
<LocationInput
|
||||
data-testid={`${eventLocationType.type}-location-input`}
|
||||
defaultValue={
|
||||
defaultLocation
|
||||
? defaultLocation[eventLocationType.defaultValueVariable]
|
||||
: undefined
|
||||
}
|
||||
eventLocationType={eventLocationType}
|
||||
index={index}
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage
|
||||
errors={formState.errors?.locations?.[index]}
|
||||
name={eventLocationType.defaultValueVariable}
|
||||
className="text-error my-1 ml-6 text-sm"
|
||||
as="div"
|
||||
id="location-error"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-6">
|
||||
<CheckboxField
|
||||
name={`locations[${index}].displayLocationPublicly`}
|
||||
data-testid="display-location"
|
||||
disabled={disableLocationProp}
|
||||
defaultChecked={defaultLocation?.displayLocationPublicly}
|
||||
description={t("display_location_label")}
|
||||
onChange={(e) => {
|
||||
const fieldValues = getValues("locations")[index];
|
||||
updateLocationField(index, {
|
||||
...fieldValues,
|
||||
displayLocationPublicly: e.target.checked,
|
||||
});
|
||||
}}
|
||||
informationIconText={t("display_location_info_badge")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{(validLocations.length === 0 || showEmptyLocationSelect) && (
|
||||
<div className="flex">
|
||||
<LocationSelect
|
||||
defaultMenuIsOpen={showEmptyLocationSelect}
|
||||
placeholder={t("select")}
|
||||
options={locationOptions}
|
||||
value={selectedNewOption}
|
||||
isDisabled={disableLocationProp}
|
||||
defaultValue={defaultValue}
|
||||
isSearchable={false}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
menuPlacement="auto"
|
||||
onChange={(e: SingleValueLocationOption) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{validLocations.some(
|
||||
(location) =>
|
||||
location.type === MeetLocationType && props.destinationCalendar?.integration !== "google_calendar"
|
||||
) && (
|
||||
<div className="text-default flex items-center text-sm">
|
||||
<div className="mr-1.5 h-3 w-3">
|
||||
<Icon name="check" className="h-3 w-3" />
|
||||
</div>
|
||||
<p className="text-default text-sm">
|
||||
<Trans i18nKey="event_type_requires_google_calendar">
|
||||
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
|
||||
Connect it
|
||||
<Link className="cursor-pointer text-blue-500 underline" href="/apps/google-calendar">
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
|
||||
<p className="pl-1 text-sm leading-none text-red-600">
|
||||
{t("app_not_connected", { appName: locationDetails.name })}{" "}
|
||||
<a className="underline" href={`${WEBAPP_URL}/apps/${locationDetails.slug}`}>
|
||||
{t("connect_now")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{validLocations.length > 0 && !disableLocationProp && (
|
||||
// && !isChildrenManagedEventType : Add this to hide add-location button only when location is disabled by Admin
|
||||
<li>
|
||||
<Button
|
||||
data-testid="add-location"
|
||||
StartIcon="plus"
|
||||
color="minimal"
|
||||
disabled={seatsEnabled}
|
||||
tooltip={seatsEnabled ? t("seats_option_doesnt_support_multi_location") : undefined}
|
||||
onClick={() => setShowEmptyLocationSelect(true)}>
|
||||
{t("add_location")}
|
||||
</Button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{props.showAppStoreLink && (
|
||||
<p className="text-default mt-2 text-sm">
|
||||
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
|
||||
Can't find the right video app? Visit our
|
||||
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
|
||||
App Store
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Locations;
|
||||
@@ -39,13 +39,17 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
|
||||
Option: (props) => {
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
<div data-testid={`location-select-item-${props.data.value}`}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
},
|
||||
SingleValue: (props) => (
|
||||
<components.SingleValue {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
<div data-testid={`location-select-item-${props.data.value}`}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
</div>
|
||||
</components.SingleValue>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -53,7 +53,6 @@ function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
|
||||
slug={data.slug}
|
||||
variant={data.variant}
|
||||
type={data.type}
|
||||
dirName={data.dirName}
|
||||
logo={data.logo}
|
||||
categories={data.categories ?? [data.category]}
|
||||
author={data.publisher}
|
||||
|
||||
@@ -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<EventTypeModel, "metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position"> & {
|
||||
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<typeof EventTypeMetaDataSchema>;
|
||||
bookingFields?: z.infer<typeof eventTypeBookingFields>;
|
||||
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<HTMLDivElement>(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 = ({
|
||||
<StepHeader
|
||||
title={stepObj.getTitle(appMetadata.name)}
|
||||
subtitle={stepObj.getDescription(appMetadata.name)}>
|
||||
<Steps maxSteps={MAX_NUMBER_OF_STEPS} currentStep={stepObj.stepNumber} disableNavigation />
|
||||
<Steps maxSteps={maxSteps} currentStep={stepObj.stepNumber} disableNavigation />
|
||||
</StepHeader>
|
||||
{currentStep === AppOnboardingSteps.ACCOUNTS_STEP && (
|
||||
<AccountsStepCard
|
||||
teams={teams}
|
||||
personalAccount={personalAccount}
|
||||
onSelect={handleSelectAccount}
|
||||
loading={isSelectingAccount}
|
||||
loading={mutation.isPending}
|
||||
installableOnTeams={installableOnTeams}
|
||||
/>
|
||||
)}
|
||||
{currentStep === AppOnboardingSteps.EVENT_TYPES_STEP &&
|
||||
@@ -278,6 +303,7 @@ const OnboardingPage = ({
|
||||
setConfigureStep={setConfigureStep}
|
||||
eventTypes={eventTypes}
|
||||
handleSetUpLater={handleSetUpLater}
|
||||
isConferencing={isConferencing}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
@@ -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" } };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -61,7 +61,6 @@ export default function OmniInstallAppButton({
|
||||
type: app.type,
|
||||
variant: app.variant,
|
||||
slug: app.slug,
|
||||
isOmniInstall: true,
|
||||
...(teamId && { teamId }),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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") };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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" }) });
|
||||
}
|
||||
|
||||
@@ -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" }) });
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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" })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }) });
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Dialog name="Account check" open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent
|
||||
@@ -24,7 +27,21 @@ export function AccountDialog(props: DialogProps) {
|
||||
Cancel
|
||||
</DialogClose>
|
||||
|
||||
<Button type="button" onClick={() => mutation.mutate("")}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
mutation.mutate({
|
||||
type: "office365_video",
|
||||
variant: "conferencing",
|
||||
slug: "msteams",
|
||||
returnTo:
|
||||
WEBAPP_URL +
|
||||
getAppOnboardingUrl({
|
||||
slug: "msteams",
|
||||
step: AppOnboardingSteps.EVENT_TYPES_STEP,
|
||||
}),
|
||||
})
|
||||
}>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" })
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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" }));
|
||||
}
|
||||
|
||||
@@ -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 }) });
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-2
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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" })
|
||||
);
|
||||
|
||||
@@ -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" })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<UserAdminTeams> => {
|
||||
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;
|
||||
@@ -118,3 +118,5 @@ export type FormValues = {
|
||||
forwardParamsSuccessRedirect: boolean | null;
|
||||
secondaryEmailId?: number;
|
||||
};
|
||||
|
||||
export type LocationFormValues = Pick<FormValues, "id" | "locations" | "bookingFields" | "seatsPerTimeSlot">;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<number[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<number | undefined>(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<boolean>(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 (
|
||||
<div className="border-subtle relative flex h-64 flex-col rounded-md border p-5">
|
||||
<div className="flex">
|
||||
@@ -117,19 +149,13 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
...props,
|
||||
onClick: () => {
|
||||
handleAppInstall();
|
||||
},
|
||||
loading: isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<InstallAppButtonChild
|
||||
userAdminTeams={userAdminTeams}
|
||||
{...props}
|
||||
addAppMutationInput={{ type: app.type, variant: app.variant, slug: app.slug }}
|
||||
appCategories={app.categories}
|
||||
concurrentMeetings={app.concurrentMeetings}
|
||||
paid={app.paid}
|
||||
dirName={app.dirName}
|
||||
/>
|
||||
);
|
||||
return <InstallAppButtonChild paid={app.paid} {...props} />;
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -145,26 +171,19 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
|
||||
props = {
|
||||
...props,
|
||||
disabled: !!props.disabled,
|
||||
onClick: () => {
|
||||
handleAppInstall();
|
||||
},
|
||||
loading: isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<InstallAppButtonChild
|
||||
userAdminTeams={userAdminTeams}
|
||||
addAppMutationInput={{ type: app.type, variant: app.variant, slug: app.slug }}
|
||||
appCategories={app.categories}
|
||||
credentials={credentials}
|
||||
concurrentMeetings={app.concurrentMeetings}
|
||||
paid={app.paid}
|
||||
dirName={app.dirName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <InstallAppButtonChild paid={app.paid} {...props} />;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-w-44 absolute right-0 mr-4 flex flex-wrap justify-end gap-1">
|
||||
{appInstalled ? <Badge variant="green">{t("installed", { count: appAdded })}</Badge> : null}
|
||||
{appAdded > 0 ? <Badge variant="green">{t("installed", { count: appAdded })}</Badge> : null}
|
||||
{app.isTemplate && (
|
||||
<span className="bg-error rounded-md px-2 py-1 text-sm font-normal text-red-800">Template</span>
|
||||
)}
|
||||
@@ -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<HTMLElement, 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")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!userAdminTeams?.length ||
|
||||
!doesAppSupportTeamInstall({ appCategories, concurrentMeetings, isPaid: !!paid })
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
|
||||
StartIcon="plus"
|
||||
data-testid="install-app-button"
|
||||
onClick={_onClick}
|
||||
{...props}>
|
||||
{t("install")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (redirectToAppOnboarding) {
|
||||
return (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
|
||||
StartIcon="plus"
|
||||
data-testid="install-app-button"
|
||||
onClick={_onClick}
|
||||
{...props}
|
||||
size="base">
|
||||
{t("install")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
|
||||
StartIcon="plus"
|
||||
data-testid="install-app-button"
|
||||
{...props}>
|
||||
{t("install")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
className="w-56"
|
||||
onInteractOutside={(event) => {
|
||||
if (mutation.isPending) event.preventDefault();
|
||||
}}>
|
||||
{mutation.isPending && (
|
||||
<div className="z-1 fixed inset-0 flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuLabel>{t("install_app_on")}</DropdownMenuLabel>
|
||||
{userAdminTeams.map((team) => {
|
||||
const isInstalledTeamOrUser =
|
||||
credentials &&
|
||||
credentials.some((credential) =>
|
||||
credential?.teamId ? credential?.teamId === team.id : credential.userId === team.id
|
||||
);
|
||||
return (
|
||||
<DropdownItem
|
||||
type="button"
|
||||
disabled={isInstalledTeamOrUser}
|
||||
key={team.id}
|
||||
CustomStartIcon={
|
||||
<Avatar
|
||||
alt={team.name || ""}
|
||||
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)} // if no image, use default avatar
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
mutation.mutate(
|
||||
team.isUser ? addAppMutationInput : { ...addAppMutationInput, teamId: team.id }
|
||||
);
|
||||
}}>
|
||||
<p className="text-left">
|
||||
{t(team.name)} {isInstalledTeamOrUser && `(${t("installed")})`}
|
||||
</p>
|
||||
</DropdownItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
|
||||
StartIcon="plus"
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
size="base">
|
||||
{t("install")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(<AppCard app={appData} />);
|
||||
const queryClient = new QueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppCard app={appData} {...appCardProps} />;
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("Tests for app description", () => {
|
||||
@@ -45,7 +51,7 @@ describe("Tests for AppCard component", () => {
|
||||
});
|
||||
|
||||
test("Should highlight the app name based on searchText", () => {
|
||||
render(<AppCard app={mockApp} searchText="test" />);
|
||||
renderAppCard({}, { searchText: "test" });
|
||||
const highlightedText = screen.getByTestId("highlighted-text");
|
||||
expect(highlightedText).toBeInTheDocument();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user