diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx index 40dfa7fd850ef8..d0b3935e6e9153 100644 --- a/apps/web/components/apps/AppPage.tsx +++ b/apps/web/components/apps/AppPage.tsx @@ -18,6 +18,7 @@ import type { App as AppType } from "@calcom/types/App"; import { Badge, Button, Icon, SkeletonButton, SkeletonText, showToast } from "@calcom/ui"; import { InstallAppButtonChild } from "./InstallAppButtonChild"; +import { MultiDisconnectIntegration } from "./MultiDisconnectIntegration"; export type AppPageProps = { name: string; @@ -98,6 +99,11 @@ export const AppPage = ({ * which is caused by heavy queries in getServersideProps. This causes the loader to turn off before the page changes. */ const [isLoading, setIsLoading] = useState(mutation.isPending); + const availableForTeams = doesAppSupportTeamInstall({ + appCategories: categories, + concurrentMeetings: concurrentMeetings, + isPaid: !!paid, + }); const handleAppInstall = () => { setIsLoading(true); @@ -113,13 +119,7 @@ export const AppPage = ({ step: AppOnboardingSteps.EVENT_TYPES_STEP, }), }); - } else if ( - !doesAppSupportTeamInstall({ - appCategories: categories, - concurrentMeetings: concurrentMeetings, - isPaid: !!paid, - }) - ) { + } else if (!availableForTeams) { mutation.mutate({ type }); } else { router.push(getAppOnboardingUrl({ slug, step: AppOnboardingSteps.ACCOUNTS_STEP })); @@ -132,8 +132,14 @@ export const AppPage = ({ useGrouping: false, }).format(price); - const [existingCredentials, setExistingCredentials] = useState([]); - const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false); + const [existingCredentials, setExistingCredentials] = useState< + NonNullable["credentials"] + >([]); + + /** + * Marks whether the app is installed for all possible teams and the user. + */ + const [appInstalledForAllTargets, setAppInstalledForAllTargets] = useState(false); const appDbQuery = trpc.viewer.appCredentialsByType.useQuery({ appType: type }); @@ -142,12 +148,15 @@ export const AppPage = ({ const data = appDbQuery.data; const credentialsCount = data?.credentials.length || 0; - setShowDisconnectIntegration( - data?.userAdminTeams.length ? credentialsCount >= data?.userAdminTeams.length : credentialsCount > 0 - ); - setExistingCredentials(data?.credentials.map((credential) => credential.id) || []); + setExistingCredentials(data?.credentials || []); + + const appInstalledForAllTargets = + availableForTeams && data?.userAdminTeams + ? credentialsCount >= data?.userAdminTeams.length + : credentialsCount > 0; + setAppInstalledForAllTargets(appInstalledForAllTargets); }, - [appDbQuery.data] + [appDbQuery.data, availableForTeams] ); const dependencyData = trpc.viewer.appsRouter.queryForDependencies.useQuery(dependencies, { @@ -168,6 +177,89 @@ export const AppPage = ({ } }, []); + const installOrDisconnectAppButton = () => { + if (appDbQuery.isPending) { + return ; + } + + const MultiInstallButtonEl = ( + { + if (useDefaultComponent) { + props = { + ...props, + onClick: () => { + handleAppInstall(); + }, + loading: isLoading, + }; + } + return ; + }} + /> + ); + + const SingleInstallButtonEl = ( + { + if (useDefaultComponent) { + props = { + ...props, + onClick: () => { + handleAppInstall(); + }, + loading: isLoading, + }; + } + return ; + }} + /> + ); + + return ( +
+ {isGlobal || + (existingCredentials.length > 0 && allowedMultipleInstalls ? ( +
+ + {!isGlobal && !appInstalledForAllTargets && MultiInstallButtonEl} +
+ ) : ( + !appInstalledForAllTargets && SingleInstallButtonEl + ))} + + {existingCredentials.length > 0 && ( + <> + {existingCredentials.length > 1 ? ( + appDbQuery.refetch()} + /> + ) : ( + appDbQuery.refetch()} + /> + )} + + )} +
+ ); + }; + return (
{hasDescriptionItems && ( @@ -240,68 +332,7 @@ export const AppPage = ({ )}
- {!appDbQuery.isPending ? ( - isGlobal || - (existingCredentials.length > 0 && allowedMultipleInstalls ? ( -
- - {!isGlobal && ( - { - if (useDefaultComponent) { - props = { - ...props, - onClick: () => { - handleAppInstall(); - }, - loading: isLoading, - }; - } - return ; - }} - /> - )} -
- ) : showDisconnectIntegration ? ( - { - appDbQuery.refetch(); - }} - /> - ) : ( - { - if (useDefaultComponent) { - props = { - ...props, - onClick: () => { - handleAppInstall(); - }, - loading: isLoading, - }; - } - return ( - - ); - }} - /> - )) - ) : ( - - )} + {installOrDisconnectAppButton()} {dependencies && (!dependencyData.isPending ? ( diff --git a/apps/web/components/apps/MultiDisconnectIntegration.tsx b/apps/web/components/apps/MultiDisconnectIntegration.tsx new file mode 100644 index 00000000000000..ed2b8d3762314f --- /dev/null +++ b/apps/web/components/apps/MultiDisconnectIntegration.tsx @@ -0,0 +1,109 @@ +import { useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import type { AppRouter } from "@calcom/trpc/server/routers/_app"; +import { + Button, + Dropdown, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuItem, + DropdownItem, + Dialog, + ConfirmationDialogContent, + showToast, +} from "@calcom/ui"; + +import type { inferRouterOutputs } from "@trpc/server"; + +type RouterOutput = inferRouterOutputs; +type Credentials = RouterOutput["viewer"]["appCredentialsByType"]["credentials"]; + +interface Props { + credentials: Credentials; + onSuccess?: () => void; +} + +export function MultiDisconnectIntegration({ credentials, onSuccess }: Props) { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const [credentialToDelete, setCredentialToDelete] = useState<{ + id: number; + teamId: number | null; + name: string | null; + } | null>(null); + const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); + + const mutation = trpc.viewer.deleteCredential.useMutation({ + onSuccess: () => { + showToast(t("app_removed_successfully"), "success"); + onSuccess && onSuccess(); + setConfirmationDialogOpen(false); + }, + onError: () => { + showToast(t("error_removing_app"), "error"); + setConfirmationDialogOpen(false); + }, + async onSettled() { + await utils.viewer.connectedCalendars.invalidate(); + await utils.viewer.integrations.invalidate(); + }, + }); + + return ( + <> + + + + + + +
{t("disconnect_app_from")}
+
+ {credentials.map((cred) => ( + + { + setCredentialToDelete({ + id: cred.id, + teamId: cred.teamId, + name: cred.team?.name || cred.user?.name || null, + }); + setConfirmationDialogOpen(true); + }}> +
+ {cred.team?.name || cred.user?.name || t("unnamed")} +
+
+
+ ))} +
+
+ + + { + if (credentialToDelete) { + mutation.mutate({ + id: credentialToDelete.id, + ...(credentialToDelete.teamId ? { teamId: credentialToDelete.teamId } : {}), + }); + } + }}> +

+ {t("are_you_sure_you_want_to_remove_this_app_from")} {credentialToDelete?.name || t("unnamed")}? +

+
+
+ + ); +} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d0450c64979bfd..ce983359888d7c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2891,6 +2891,8 @@ "calendar_conflict_settings": "Calendar Conflict Settings", "select_calendars_conflict_check": "Select which calendars to check for conflicts", "group_meeting": "Group Meeting", + "are_you_sure_you_want_to_remove_this_app_from": "Are you sure you want to remove this app from", + "disconnect_app_from": "Disconnect app from", "salesforce_ignore_guests": "Do not create new records for guests added to the booking", "google_meet": "Google Meet", "zoom": "Zoom", diff --git a/packages/features/apps/components/DisconnectIntegration.tsx b/packages/features/apps/components/DisconnectIntegration.tsx index bb3a720beb1112..0e86cbb8a5bbc0 100644 --- a/packages/features/apps/components/DisconnectIntegration.tsx +++ b/packages/features/apps/components/DisconnectIntegration.tsx @@ -9,6 +9,7 @@ import { DisconnectIntegrationComponent, showToast } from "@calcom/ui"; export default function DisconnectIntegration(props: { credentialId: number; + teamId?: number | null; label?: string; trashIcon?: boolean; isGlobal?: boolean; @@ -16,7 +17,7 @@ export default function DisconnectIntegration(props: { buttonProps?: ButtonProps; }) { const { t } = useLocale(); - const { onSuccess, credentialId } = props; + const { onSuccess, credentialId, teamId } = props; const [modalOpen, setModalOpen] = useState(false); const utils = trpc.useUtils(); @@ -38,7 +39,7 @@ export default function DisconnectIntegration(props: { return ( mutation.mutate({ id: credentialId })} + onDeletionConfirmation={() => mutation.mutate({ id: credentialId, ...(teamId ? { teamId } : {}) })} isModalOpen={modalOpen} onModalOpen={() => setModalOpen((prevValue) => !prevValue)} {...props} diff --git a/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts index 114c25e4c31739..1fc70ce6299974 100644 --- a/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts @@ -29,6 +29,18 @@ export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentials ], type: input.appType, }, + include: { + user: { + select: { + name: true, + }, + }, + team: { + select: { + name: true, + }, + }, + }, }); // For app pages need to return which teams the user can install the app on