fix: No option to install/disconnect app from app detail page (#17997)

* fix: No option to install/disconnect app from app detail page

* Fix types

* fix: Install button showing up even after it is installed for all targers

* chore: Simplified installOrDisconnectAppButton making it easy to read

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Anwar Sadath
2025-02-26 00:08:59 +05:30
committed by GitHub
co-authored by Hariom Peer Richelsen
parent a3113607ff
commit a4519e705d
5 changed files with 233 additions and 78 deletions
+107 -76
View File
@@ -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<boolean>(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<number[]>([]);
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
const [existingCredentials, setExistingCredentials] = useState<
NonNullable<typeof appDbQuery.data>["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 && data.userAdminTeams.length > 0
? 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 <SkeletonButton className="h-10 w-24" />;
}
const MultiInstallButtonEl = (
<InstallAppButton
type={type}
disableInstall={disableInstall}
teamsPlanRequired={teamsPlanRequired}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
...props,
onClick: () => {
handleAppInstall();
},
loading: isLoading,
};
}
return <InstallAppButtonChild multiInstall paid={paid} {...props} />;
}}
/>
);
const SingleInstallButtonEl = (
<InstallAppButton
type={type}
disableInstall={disableInstall}
teamsPlanRequired={teamsPlanRequired}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
...props,
onClick: () => {
handleAppInstall();
},
loading: isLoading,
};
}
return <InstallAppButtonChild credentials={appDbQuery.data?.credentials} paid={paid} {...props} />;
}}
/>
);
return (
<div className="flex items-center space-x-3">
{isGlobal ||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
<div className="flex space-x-3">
<Button StartIcon="check" color="secondary" disabled>
{existingCredentials.length > 0
? t("active_install", { count: existingCredentials.length })
: t("default")}
</Button>
{!isGlobal && !appInstalledForAllTargets && MultiInstallButtonEl}
</div>
) : (
!appInstalledForAllTargets && SingleInstallButtonEl
))}
{existingCredentials.length > 0 && (
<>
{existingCredentials.length > 1 ? (
<MultiDisconnectIntegration
credentials={existingCredentials}
onSuccess={() => appDbQuery.refetch()}
/>
) : (
<DisconnectIntegration
buttonProps={{ color: "secondary" }}
label={t("disconnect")}
credentialId={Number(existingCredentials[0].id)}
teamId={existingCredentials[0].teamId}
onSuccess={() => appDbQuery.refetch()}
/>
)}
</>
)}
</div>
);
};
return (
<div className="relative mt-4 flex-1 flex-col items-start justify-start px-4 md:mt-0 md:flex md:px-8 lg:flex-row lg:px-0">
{hasDescriptionItems && (
@@ -240,68 +332,7 @@ export const AppPage = ({
)}
</header>
</div>
{!appDbQuery.isPending ? (
isGlobal ||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
<div className="flex space-x-3">
<Button StartIcon="check" color="secondary" disabled>
{existingCredentials.length > 0
? t("active_install", { count: existingCredentials.length })
: t("default")}
</Button>
{!isGlobal && (
<InstallAppButton
type={type}
disableInstall={disableInstall}
teamsPlanRequired={teamsPlanRequired}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
...props,
onClick: () => {
handleAppInstall();
},
loading: isLoading,
};
}
return <InstallAppButtonChild multiInstall paid={paid} {...props} />;
}}
/>
)}
</div>
) : showDisconnectIntegration ? (
<DisconnectIntegration
buttonProps={{ color: "secondary" }}
label={t("disconnect")}
credentialId={existingCredentials[0]}
onSuccess={() => {
appDbQuery.refetch();
}}
/>
) : (
<InstallAppButton
type={type}
disableInstall={disableInstall}
teamsPlanRequired={teamsPlanRequired}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
...props,
onClick: () => {
handleAppInstall();
},
loading: isLoading,
};
}
return (
<InstallAppButtonChild credentials={appDbQuery.data?.credentials} paid={paid} {...props} />
);
}}
/>
))
) : (
<SkeletonButton className="h-10 w-24" />
)}
{installOrDisconnectAppButton()}
{dependencies &&
(!dependencyData.isPending ? (
@@ -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<AppRouter>;
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 (
<>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button color="secondary">{t("disconnect")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>
<div className="w-48 text-left text-xs">{t("disconnect_app_from")}</div>
</DropdownMenuLabel>
{credentials.map((cred) => (
<DropdownMenuItem key={cred.id}>
<DropdownItem
type="button"
color="destructive"
className="hover:bg-subtle hover:text-emphasis w-full border-0"
StartIcon={cred.teamId ? "users" : "user"}
onClick={() => {
setCredentialToDelete({
id: cred.id,
teamId: cred.teamId,
name: cred.team?.name || cred.user?.name || null,
});
setConfirmationDialogOpen(true);
}}>
<div className="flex flex-col text-left">
<span>{cred.team?.name || cred.user?.name || t("unnamed")}</span>
</div>
</DropdownItem>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
<Dialog open={confirmationDialogOpen} onOpenChange={setConfirmationDialogOpen}>
<ConfirmationDialogContent
variety="danger"
title={t("remove_app")}
confirmBtnText={t("yes_remove_app")}
onConfirm={() => {
if (credentialToDelete) {
mutation.mutate({
id: credentialToDelete.id,
...(credentialToDelete.teamId ? { teamId: credentialToDelete.teamId } : {}),
});
}
}}>
<p className="mt-5">
{t("are_you_sure_you_want_to_remove_this_app_from")} {credentialToDelete?.name || t("unnamed")}?
</p>
</ConfirmationDialogContent>
</Dialog>
</>
);
}
@@ -2901,6 +2901,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",
@@ -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 (
<DisconnectIntegrationComponent
onDeletionConfirmation={() => mutation.mutate({ id: credentialId })}
onDeletionConfirmation={() => mutation.mutate({ id: credentialId, ...(teamId ? { teamId } : {}) })}
isModalOpen={modalOpen}
onModalOpen={() => setModalOpen((prevValue) => !prevValue)}
{...props}
@@ -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