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:
co-authored by
Hariom
Peer Richelsen
parent
a3113607ff
commit
a4519e705d
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user