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:
Somay Chauhan
2024-07-08 19:15:57 +02:00
committed by GitHub
co-authored by Joe Au-Yeung Omar López Joe Au-Yeung
parent 7d8cb7d6bb
commit f1b4d7d9e0
57 changed files with 1471 additions and 1224 deletions
+48 -29
View File
@@ -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;
+19 -386
View File
@@ -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&apos;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>
+447
View File
@@ -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&apos;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>
),
}}
+3 -3
View File
@@ -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 -3
View File
@@ -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);
-1
View File
@@ -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}
+124 -56
View File
@@ -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);
});
});
});
+11 -23
View File
@@ -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
*/
+87 -30
View File
@@ -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();
},
};
}
+19
View File
@@ -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();
}
+1
View File
@@ -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"]
+2 -8
View File
@@ -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" });
+26 -35
View File
@@ -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") };
},
});
+2 -1
View File
@@ -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" }) });
}
+4 -2
View File
@@ -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" })
+4 -1
View File
@@ -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" })
);
}
+4 -2
View File
@@ -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>
</>
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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" })
);
+4 -1
View File
@@ -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" }));
}
+4 -1
View File
@@ -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" })
);
}
}
+1 -2
View File
@@ -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;
};
+4 -1
View File
@@ -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",
];
+6 -1
View File
@@ -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 })
);
}
+2 -1
View File
@@ -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" })
);
+6 -1
View File
@@ -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
View File
@@ -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;
}
+26
View File
@@ -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,
},
},
],
+1 -1
View File
@@ -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";
+71 -172
View File
@@ -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>
);
};
+9 -3
View File
@@ -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();
});