From 1e03777b5ad37616b5c8da63631d94c157d089f7 Mon Sep 17 00:00:00 2001 From: Anwar Sadath Date: Wed, 4 Dec 2024 16:30:23 +0530 Subject: [PATCH 1/4] fix: No option to install/disconnect app from app detail page --- apps/web/components/apps/AppPage.tsx | 179 ++++++++++-------- .../apps/MultiDisconnectIntegration.tsx | 104 ++++++++++ apps/web/public/static/locales/en/common.json | 2 + .../apps/components/DisconnectIntegration.tsx | 5 +- .../appCredentialsByType.handler.ts | 12 ++ 5 files changed, 225 insertions(+), 77 deletions(-) create mode 100644 apps/web/components/apps/MultiDisconnectIntegration.tsx diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx index 8294a0dff77118..e041a8e5964742 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 enabledOnTeams = 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 (!enabledOnTeams) { mutation.mutate({ type }); } else { router.push(getAppOnboardingUrl({ slug, step: AppOnboardingSteps.ACCOUNTS_STEP })); @@ -132,8 +132,10 @@ export const AppPage = ({ useGrouping: false, }).format(price); - const [existingCredentials, setExistingCredentials] = useState([]); - const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false); + const [existingCredentials, setExistingCredentials] = useState< + NonNullable["credentials"] + >([]); + const [appInstalled, setAppInstalled] = useState(false); const appDbQuery = trpc.viewer.appCredentialsByType.useQuery({ appType: type }); @@ -142,10 +144,13 @@ 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 appInstalled = + enabledOnTeams && data?.userAdminTeams + ? data?.userAdminTeams.length < credentialsCount + : credentialsCount > 0; + setAppInstalled(appInstalled); }, [appDbQuery.data] ); @@ -168,6 +173,91 @@ export const AppPage = ({ } }, []); + const installOrDisconnectAppButton = () => { + if (appDbQuery.isPending) { + return ; + } + + return ( +
+ {isGlobal || + (existingCredentials.length > 0 && allowedMultipleInstalls ? ( +
+ + {!isGlobal && !appInstalled && ( + { + if (useDefaultComponent) { + props = { + ...props, + onClick: () => { + handleAppInstall(); + }, + loading: isLoading, + }; + } + return ; + }} + /> + )} +
+ ) : ( + !appInstalled && ( + { + if (useDefaultComponent) { + props = { + ...props, + onClick: () => { + handleAppInstall(); + }, + loading: isLoading, + }; + } + return ( + + ); + }} + /> + ) + ))} + + {existingCredentials.length > 0 && ( + <> + {existingCredentials.length > 1 ? ( + appDbQuery.refetch()} + /> + ) : ( + appDbQuery.refetch()} + /> + )} + + )} +
+ ); + }; + return (
{hasDescriptionItems && ( @@ -240,68 +330,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..2f28046eb4c551 --- /dev/null +++ b/apps/web/components/apps/MultiDisconnectIntegration.tsx @@ -0,0 +1,104 @@ +import type { Credential } from "@prisma/client"; +import { useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { + Button, + Dropdown, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuItem, + DropdownItem, + Dialog, + ConfirmationDialogContent, + showToast, +} from "@calcom/ui"; + +interface MultiDisconnectIntegrationProps { + credentials: Credential[]; + onSuccess?: () => void; +} + +export function MultiDisconnectIntegration({ credentials, onSuccess }: MultiDisconnectIntegrationProps) { + 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 1c2067e65d797d..88b0cb753b2355 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2826,6 +2826,8 @@ "single_select": "Single Select", "multi_select": "Multi Select", "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", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/apps/components/DisconnectIntegration.tsx b/packages/features/apps/components/DisconnectIntegration.tsx index bb3a720beb1112..6c1cc3a3741956 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; 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 From 1cdd9087190d4de9b7a5f5bdff423a2e1ca5900b Mon Sep 17 00:00:00 2001 From: Anwar Sadath Date: Wed, 4 Dec 2024 18:45:30 +0530 Subject: [PATCH 2/4] Fix types --- apps/web/components/apps/AppPage.tsx | 2 +- .../components/apps/MultiDisconnectIntegration.tsx | 13 +++++++++---- .../apps/components/DisconnectIntegration.tsx | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx index e041a8e5964742..daffd41ad54058 100644 --- a/apps/web/components/apps/AppPage.tsx +++ b/apps/web/components/apps/AppPage.tsx @@ -247,7 +247,7 @@ export const AppPage = ({ appDbQuery.refetch()} /> diff --git a/apps/web/components/apps/MultiDisconnectIntegration.tsx b/apps/web/components/apps/MultiDisconnectIntegration.tsx index 2f28046eb4c551..ed2b8d3762314f 100644 --- a/apps/web/components/apps/MultiDisconnectIntegration.tsx +++ b/apps/web/components/apps/MultiDisconnectIntegration.tsx @@ -1,8 +1,8 @@ -import type { Credential } from "@prisma/client"; 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, @@ -16,12 +16,17 @@ import { showToast, } from "@calcom/ui"; -interface MultiDisconnectIntegrationProps { - credentials: Credential[]; +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 }: MultiDisconnectIntegrationProps) { +export function MultiDisconnectIntegration({ credentials, onSuccess }: Props) { const { t } = useLocale(); const utils = trpc.useUtils(); const [credentialToDelete, setCredentialToDelete] = useState<{ diff --git a/packages/features/apps/components/DisconnectIntegration.tsx b/packages/features/apps/components/DisconnectIntegration.tsx index 6c1cc3a3741956..0e86cbb8a5bbc0 100644 --- a/packages/features/apps/components/DisconnectIntegration.tsx +++ b/packages/features/apps/components/DisconnectIntegration.tsx @@ -9,7 +9,7 @@ import { DisconnectIntegrationComponent, showToast } from "@calcom/ui"; export default function DisconnectIntegration(props: { credentialId: number; - teamId?: number; + teamId?: number | null; label?: string; trashIcon?: boolean; isGlobal?: boolean; From 2f9888e7576c242a096fe2987974a0cf1ce0134b Mon Sep 17 00:00:00 2001 From: Hariom Date: Wed, 1 Jan 2025 18:13:45 +0530 Subject: [PATCH 3/4] fix: Install button showing up even after it is installed for all targers --- apps/web/components/apps/AppPage.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx index cbda44c83a1299..c1d249820fa1b8 100644 --- a/apps/web/components/apps/AppPage.tsx +++ b/apps/web/components/apps/AppPage.tsx @@ -135,7 +135,11 @@ export const AppPage = ({ const [existingCredentials, setExistingCredentials] = useState< NonNullable["credentials"] >([]); - const [appInstalled, setAppInstalled] = useState(false); + + /** + * 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 }); @@ -146,11 +150,11 @@ export const AppPage = ({ const credentialsCount = data?.credentials.length || 0; setExistingCredentials(data?.credentials || []); - const appInstalled = + const appInstalledForAllTargets = enabledOnTeams && data?.userAdminTeams - ? data?.userAdminTeams.length < credentialsCount + ? credentialsCount >= data?.userAdminTeams.length : credentialsCount > 0; - setAppInstalled(appInstalled); + setAppInstalledForAllTargets(appInstalledForAllTargets); }, [appDbQuery.data] ); @@ -188,7 +192,7 @@ export const AppPage = ({ ? t("active_install", { count: existingCredentials.length }) : t("default")} - {!isGlobal && !appInstalled && ( + {!isGlobal && !appInstalledForAllTargets && ( ) : ( - !appInstalled && ( + !appInstalledForAllTargets && ( Date: Wed, 1 Jan 2025 18:32:50 +0530 Subject: [PATCH 4/4] chore: Simplified installOrDisconnectAppButton making it easy to read --- apps/web/components/apps/AppPage.tsx | 94 ++++++++++++++-------------- 1 file changed, 46 insertions(+), 48 deletions(-) diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx index c1d249820fa1b8..d0b3935e6e9153 100644 --- a/apps/web/components/apps/AppPage.tsx +++ b/apps/web/components/apps/AppPage.tsx @@ -99,7 +99,7 @@ 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 enabledOnTeams = doesAppSupportTeamInstall({ + const availableForTeams = doesAppSupportTeamInstall({ appCategories: categories, concurrentMeetings: concurrentMeetings, isPaid: !!paid, @@ -119,7 +119,7 @@ export const AppPage = ({ step: AppOnboardingSteps.EVENT_TYPES_STEP, }), }); - } else if (!enabledOnTeams) { + } else if (!availableForTeams) { mutation.mutate({ type }); } else { router.push(getAppOnboardingUrl({ slug, step: AppOnboardingSteps.ACCOUNTS_STEP })); @@ -151,12 +151,12 @@ export const AppPage = ({ setExistingCredentials(data?.credentials || []); const appInstalledForAllTargets = - enabledOnTeams && data?.userAdminTeams + availableForTeams && data?.userAdminTeams ? credentialsCount >= data?.userAdminTeams.length : credentialsCount > 0; setAppInstalledForAllTargets(appInstalledForAllTargets); }, - [appDbQuery.data] + [appDbQuery.data, availableForTeams] ); const dependencyData = trpc.viewer.appsRouter.queryForDependencies.useQuery(dependencies, { @@ -182,6 +182,46 @@ export const AppPage = ({ 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 || @@ -192,52 +232,10 @@ export const AppPage = ({ ? t("active_install", { count: existingCredentials.length }) : t("default")} - {!isGlobal && !appInstalledForAllTargets && ( - { - if (useDefaultComponent) { - props = { - ...props, - onClick: () => { - handleAppInstall(); - }, - loading: isLoading, - }; - } - return ; - }} - /> - )} + {!isGlobal && !appInstalledForAllTargets && MultiInstallButtonEl}
) : ( - !appInstalledForAllTargets && ( - { - if (useDefaultComponent) { - props = { - ...props, - onClick: () => { - handleAppInstall(); - }, - loading: isLoading, - }; - } - return ( - - ); - }} - /> - ) + !appInstalledForAllTargets && SingleInstallButtonEl ))} {existingCredentials.length > 0 && (