fix: installed apps and admin apps layout spacing issues. (#6753)

* feat: add highlight props

* feat: add isTemplate prop

* feat: add invalid credintial prop

* feat: add children prop

* feat: render app list card

* fix: add horizontal gap

* fix: installed app layout style

* fix: admin layout

* feat: add isGlobal to returned data

* feat: fix admin tab.

* use common components list and AppListCard

* show isDefault Badge

* hide switch for global apps

* fix: show switch

* fix: remove isglobal

* fix: layout

* refactor: remove unused component and import

* feat: use app list card component

* fix: query param

* fix: prevent unnecessary props passed to the component

* feat: add disabled style

* feat: add disconnect integration modal

* feat: new changes.

* render disconnect integration model only once.

* add dropdown for actions

* feat: changes in admin apps.

* use dropdown for selecting action buttons.

* use modal for editing keys.

* Remove boolean comparison

---------

Co-authored-by: Alan <alannnc@gmail.com>
This commit is contained in:
Nafees Nazik 2023-02-01 03:15:38 +05:30 committed by GitHub
parent 4f9aa8bd96
commit e121615d36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 419 additions and 274 deletions

View File

@ -1,35 +1,93 @@
import { ReactNode } from "react";
import { useRouter } from "next/router";
import { ReactNode, useEffect, useRef, useState } from "react";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { Badge, ListItemText } from "@calcom/ui";
import { FiAlertCircle } from "@calcom/ui/components/icon";
interface AppListCardProps {
type ShouldHighlight = { slug: string; shouldHighlight: true } | { shouldHighlight?: never; slug?: never };
type AppListCardProps = {
logo?: string;
title: string;
description: string;
actions?: ReactNode;
isDefault?: boolean;
}
isTemplate?: boolean;
invalidCredential?: boolean;
children?: ReactNode;
} & ShouldHighlight;
const schema = z.object({ hl: z.string().optional() });
export default function AppListCard(props: AppListCardProps) {
const { t } = useLocale();
const { logo, title, description, actions, isDefault } = props;
const {
logo,
title,
description,
actions,
isDefault,
slug,
shouldHighlight,
isTemplate,
invalidCredential,
children,
} = props;
const {
data: { hl },
} = useTypedQuery(schema);
const router = useRouter();
const [highlight, setHighlight] = useState(shouldHighlight && hl === slug);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (shouldHighlight && highlight) {
const timer = setTimeout(() => {
setHighlight(false);
const url = new URL(window.location.href);
url.searchParams.delete("hl");
router.replace(url.pathname, undefined, { shallow: true });
}, 3000);
timeoutRef.current = timer;
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
return (
<div className="p-4">
<div className="flex items-center gap-x-3">
<div className={`p-4 ${highlight ? "bg-yellow-100" : ""}`}>
<div className="flex gap-x-3">
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
<div className="flex grow flex-col gap-y-1 truncate">
<div className="flex items-center gap-x-2">
<h3 className="truncate text-sm font-semibold text-gray-900">{title}</h3>
{isDefault ? <Badge variant="green">{t("default")}</Badge> : null}
<div className="flex items-center gap-x-2">
{isDefault && <Badge variant="green">{t("default")}</Badge>}
{isTemplate && <Badge variant="red">Template</Badge>}
</div>
</div>
<ListItemText component="p">{description}</ListItemText>
{invalidCredential && (
<div className="flex gap-x-2 pt-2">
<FiAlertCircle className="h-8 w-8 text-red-500 sm:h-4 sm:w-4" />
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
{t("invalid_credential")}
</ListItemText>
</div>
)}
</div>
{actions}
</div>
{children && <div className="w-full">{children}</div>}
</div>
);
}

View File

@ -21,8 +21,8 @@ import { FiArrowLeft, FiCalendar, FiPlus } from "@calcom/ui/components/icon";
import { QueryCell } from "@lib/QueryCell";
import AppListCard from "@components/AppListCard";
import AdditionalCalendarSelector from "@components/apps/AdditionalCalendarSelector";
import IntegrationListItem from "@components/apps/IntegrationListItem";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
type Props = {
@ -118,13 +118,13 @@ function CalendarList(props: Props) {
success={({ data }) => (
<List>
{data.items.map((item) => (
<IntegrationListItem
name={item.name}
slug={item.slug}
key={item.title}
title={item.title}
<AppListCard
title={item.name}
key={item.name}
logo={item.logo}
description={item.description}
shouldHighlight
slug={item.slug}
actions={
<InstallAppButton
type={item.type}
@ -161,16 +161,16 @@ function ConnectedCalendarsList(props: Props) {
}
return (
<List className="flex flex-col gap-6" noBorderTreatment>
<List>
{data.connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{item.calendars ? (
<IntegrationListItem
<AppListCard
shouldHighlight
slug={item.integration.slug}
title={item.integration.title}
title={item.integration.name}
logo={item.integration.logo}
description={item.primary?.email ?? item.integration.description}
separate={true}
actions={
<div className="flex w-32 justify-end">
<DisconnectIntegration
@ -200,7 +200,7 @@ function ConnectedCalendarsList(props: Props) {
</>
)}
</div>
</IntegrationListItem>
</AppListCard>
) : (
<Alert
severity="warning"

View File

@ -1,81 +0,0 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge, ListItem, ListItemText, ListItemTitle, showToast } from "@calcom/ui";
import { FiAlertCircle } from "@calcom/ui/components/icon";
import classNames from "@lib/classNames";
function IntegrationListItem(props: {
imageSrc?: string;
slug: string;
name?: string;
title?: string;
description: string;
actions?: ReactNode;
children?: ReactNode;
logo: string;
destination?: boolean;
separate?: boolean;
invalidCredential?: boolean;
isTemplate?: boolean;
}): JSX.Element {
const { t } = useLocale();
const router = useRouter();
const { hl } = router.query;
const [highlight, setHighlight] = useState(hl === props.slug);
const title = props.name || props.title;
// The highlight is to show a newly installed app, coming from the app's
// redirection after installation, so we proceed to show the corresponding
// message
if (highlight) {
showToast(t("app_successfully_installed"), "success");
}
useEffect(() => {
const timer = setTimeout(() => setHighlight(false), 3000);
return () => {
clearTimeout(timer);
};
}, []);
return (
<ListItem
expanded={!!props.children}
className={classNames(
props.separate ? "rounded-md" : "first:rounded-t-md last:rounded-b-md",
"my-0 flex-col border transition-colors duration-500",
highlight ? "bg-yellow-100" : ""
)}>
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse")}>
{props.logo && <img className="h-11 w-11" src={props.logo} alt={title} />}
<div className="flex-grow truncate pl-2">
<ListItemTitle component="h3" className="flex ">
<Link href={"/apps/" + props.slug}>{props.name || title}</Link>
{props.isTemplate && (
<Badge variant="red" className="ml-4">
Template
</Badge>
)}
</ListItemTitle>
<ListItemText component="p">{props.description}</ListItemText>
{/* Alert error that key stopped working. */}
{props.invalidCredential && (
<div className="flex items-center space-x-2 rtl:space-x-reverse">
<FiAlertCircle className="w-8 text-red-500 sm:w-4" />
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
{t("invalid_credential")}
</ListItemText>
</div>
)}
</div>
<div>{props.actions}</div>
</div>
{props.children && <div className="w-full">{props.children}</div>}
</ListItem>
);
}
export default IntegrationListItem;

View File

@ -57,9 +57,7 @@ export default function InstalledAppsLayout({
return (
<Shell {...rest}>
<AppCategoryNavigation
baseURL="/apps/installed"
containerClassname="w-full xl:mx-5 xl:w-4/5 xl:max-w-2xl xl:pr-5">
<AppCategoryNavigation baseURL="/apps/installed" containerClassname="min-w-0 w-full">
{children}
</AppCategoryNavigation>
</Shell>

View File

@ -10,6 +10,7 @@ import { UserPermissionRole } from ".prisma/client";
export default function AdminLayout({
children,
...rest
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
const session = useSession();
@ -22,10 +23,11 @@ export default function AdminLayout({
}
}, [session, router]);
const isAppsPage = router.asPath.startsWith("/settings/admin/apps");
return (
<SettingsLayout {...rest}>
<div className="mx-auto flex max-w-4xl flex-row divide-y divide-gray-200">
<div className="flex flex-1 [&>*]:flex-1">
<div className={isAppsPage ? "min-w-0" : "flex flex-1 [&>*]:flex-1"}>
<ErrorBoundary>{children}</ErrorBoundary>
</div>
</div>

View File

@ -1,10 +1,11 @@
import { useRouter } from "next/router";
import { useReducer } from "react";
import z from "zod";
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
import { InstallAppButton } from "@calcom/app-store/components";
import { InstalledAppVariants } from "@calcom/app-store/utils";
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RouterOutputs, trpc } from "@calcom/trpc/react";
import { App } from "@calcom/types/App";
@ -16,21 +17,28 @@ import {
List,
AppSkeletonLoader as SkeletonLoader,
ShellSubHeading,
DropdownMenuTrigger,
DropdownMenuContent,
Dropdown,
DropdownMenuItem,
DropdownItem,
} from "@calcom/ui";
import {
FiBarChart,
FiCalendar,
FiCreditCard,
FiGrid,
FiMoreHorizontal,
FiPlus,
FiShare2,
FiTrash,
FiVideo,
} from "@calcom/ui/components/icon";
import { QueryCell } from "@lib/QueryCell";
import AppListCard from "@components/AppListCard";
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
import IntegrationListItem from "@components/apps/IntegrationListItem";
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
function ConnectOrDisconnectIntegrationButton(props: {
@ -39,8 +47,9 @@ function ConnectOrDisconnectIntegrationButton(props: {
isGlobal?: boolean;
installed?: boolean;
invalidCredentialIds?: number[];
handleDisconnect: (credentialId: number) => void;
}) {
const { type, credentialIds, isGlobal, installed } = props;
const { type, credentialIds, isGlobal, installed, handleDisconnect } = props;
const { t } = useLocale();
const [credentialId] = credentialIds;
@ -49,25 +58,24 @@ function ConnectOrDisconnectIntegrationButton(props: {
utils.viewer.integrations.invalidate();
};
if (credentialId) {
if (type === "stripe_payment") {
return (
<DisconnectIntegration
credentialId={credentialId}
trashIcon
onSuccess={handleOpenChange}
buttonProps={{ className: "border border-gray-300" }}
/>
);
}
if (credentialId || type === "stripe_payment" || isGlobal) {
return (
<DisconnectIntegration
credentialId={credentialId}
trashIcon
onSuccess={handleOpenChange}
buttonProps={{ className: "border border-gray-300" }}
/>
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button StartIcon={FiMoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => handleDisconnect(credentialId)}
disabled={isGlobal}
StartIcon={FiTrash}>
{t("remove_app")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
);
}
@ -78,14 +86,7 @@ function ConnectOrDisconnectIntegrationButton(props: {
</div>
);
}
/** We don't need to "Connect", just show that it's installed */
if (isGlobal) {
return (
<div className="truncate px-3 py-2">
<h3 className="text-sm font-medium text-gray-700">{t("default")}</h3>
</div>
);
}
return (
<InstallAppButton
type={type}
@ -102,48 +103,54 @@ function ConnectOrDisconnectIntegrationButton(props: {
interface IntegrationsContainerProps {
variant?: typeof InstalledAppVariants[number];
exclude?: typeof InstalledAppVariants[number][];
handleDisconnect: (credentialId: number) => void;
}
interface IntegrationsListProps {
variant?: IntegrationsContainerProps["variant"];
data: RouterOutputs["viewer"]["integrations"];
handleDisconnect: (credentialId: number) => void;
}
const IntegrationsList = ({ data }: IntegrationsListProps) => {
const IntegrationsList = ({ data, handleDisconnect }: IntegrationsListProps) => {
return (
<List className="flex flex-col gap-6" noBorderTreatment>
<List>
{data.items
.filter((item) => item.invalidCredentialIds)
.map((item) => (
<IntegrationListItem
name={item.name}
slug={item.slug}
key={item.title}
title={item.title}
logo={item.logo}
<AppListCard
key={item.name}
description={item.description}
separate={true}
isTemplate={item.isTemplate}
title={item.name}
logo={item.logo}
isDefault={item.isGlobal}
shouldHighlight
slug={item.slug}
invalidCredential={item.invalidCredentialIds.length > 0}
actions={
<div className="flex w-16 justify-end">
<div className="flex justify-end">
<ConnectOrDisconnectIntegrationButton
credentialIds={item.credentialIds}
type={item.type}
isGlobal={item.isGlobal}
installed
invalidCredentialIds={item.invalidCredentialIds}
handleDisconnect={handleDisconnect}
/>
</div>
}>
<AppSettings slug={item.slug} />
</IntegrationListItem>
</AppListCard>
))}
</List>
);
};
const IntegrationsContainer = ({ variant, exclude }: IntegrationsContainerProps): JSX.Element => {
const IntegrationsContainer = ({
variant,
exclude,
handleDisconnect,
}: IntegrationsContainerProps): JSX.Element => {
const { t } = useLocale();
const query = trpc.viewer.integrations.useQuery({ variant, exclude, onlyInstalled: true });
const emptyIcon = {
@ -182,7 +189,7 @@ const IntegrationsContainer = ({ variant, exclude }: IntegrationsContainerProps)
</Button>
}
/>
<IntegrationsList data={data} variant={variant} />
<IntegrationsList handleDisconnect={handleDisconnect} data={data} variant={variant} />
</div>
) : (
<EmptyScreen
@ -214,6 +221,11 @@ const querySchema = z.object({
type querySchemaType = z.infer<typeof querySchema>;
type ModalState = {
isOpen: boolean;
credentialId: null | number;
};
export default function InstalledApps() {
const { t } = useLocale();
const router = useRouter();
@ -226,14 +238,43 @@ export default function InstalledApps() {
"web3",
];
const [data, updateData] = useReducer(
(data: ModalState, partialData: Partial<ModalState>) => ({ ...data, ...partialData }),
{
isOpen: false,
credentialId: null,
}
);
const handleModelClose = () => {
updateData({ isOpen: false, credentialId: null });
};
const handleDisconnect = (credentialId: number) => {
updateData({ isOpen: true, credentialId });
};
return (
<InstalledAppsLayout heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")}>
{categoryList.includes(category) && <IntegrationsContainer variant={category} />}
{category === "calendar" && <CalendarListContainer />}
{category === "other" && (
<IntegrationsContainer variant={category} exclude={[...categoryList, "calendar"]} />
)}
</InstalledAppsLayout>
<>
<InstalledAppsLayout heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")}>
{categoryList.includes(category) && (
<IntegrationsContainer handleDisconnect={handleDisconnect} variant={category} />
)}
{category === "calendar" && <CalendarListContainer />}
{category === "other" && (
<IntegrationsContainer
handleDisconnect={handleDisconnect}
variant={category}
exclude={[...categoryList, "calendar"]}
/>
)}
</InstalledAppsLayout>
<DisconnectIntegrationModal
handleModelClose={handleModelClose}
isOpen={data.isOpen}
credentialId={data.credentialId}
/>
</>
);
}

View File

@ -25,7 +25,7 @@ const AppCategoryNavigation = ({
const appCategories = useMemo(() => getAppCategories(baseURL, useQueryParam), [baseURL, useQueryParam]);
return (
<div className={classNames("flex flex-col p-2 md:p-0 xl:flex-row", className)}>
<div className={classNames("flex flex-col gap-x-6 p-2 md:p-0 xl:flex-row", className)}>
<div className="hidden xl:block">
<VerticalTabs
tabs={appCategories}

View File

@ -1,8 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { AppCategories } from "@prisma/client";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import { useRouter } from "next/router";
import { useState } from "react";
import { useState, useReducer, FC } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
@ -11,41 +10,62 @@ import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RouterOutputs, trpc } from "@calcom/trpc/react";
import {
Badge,
Button,
ConfirmationDialogContent,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyScreen,
Form,
List,
showToast,
SkeletonButton,
SkeletonContainer,
SkeletonText,
Switch,
TextField,
VerticalDivider,
} from "@calcom/ui";
import { FiAlertCircle, FiEdit } from "@calcom/ui/components/icon";
import {
FiAlertCircle,
FiEdit,
FiMoreHorizontal,
FiCheckCircle,
FiXCircle,
} from "@calcom/ui/components/icon";
import AppListCard from "../../../apps/web/components/AppListCard";
type App = RouterOutputs["viewer"]["appsRouter"]["listLocal"][number];
const IntegrationContainer = ({
app,
lastEntry,
category,
handleModelOpen,
}: {
app: RouterOutputs["viewer"]["appsRouter"]["listLocal"][number];
lastEntry: boolean;
app: App;
category: string;
handleModelOpen: (data: EditModalState) => void;
}) => {
const { t } = useLocale();
const utils = trpc.useContext();
const [disableDialog, setDisableDialog] = useState(false);
const [showKeys, setShowKeys] = useState(false);
const appKeySchema = appKeysSchemas[app.dirName as keyof typeof appKeysSchemas];
const formMethods = useForm({
resolver: zodResolver(appKeySchema),
});
const showKeyModal = () => {
if (app.keys) {
handleModelOpen({
dirName: app.dirName,
keys: app.keys,
slug: app.slug,
type: app.type,
isOpen: "editKeys",
});
}
};
const enableAppMutation = trpc.viewer.appsRouter.toggle.useMutation({
onSuccess: (enabled) => {
@ -55,15 +75,9 @@ const IntegrationContainer = ({
enabled ? t("app_is_enabled", { appName: app.name }) : t("app_is_disabled", { appName: app.name }),
"success"
);
},
onError: (error) => {
showToast(error.message, "error");
},
});
const saveKeysMutation = trpc.viewer.appsRouter.saveKeys.useMutation({
onSuccess: () => {
showToast(t("keys_have_been_saved"), "success");
if (enabled) {
showKeyModal();
}
},
onError: (error) => {
showToast(error.message, "error");
@ -71,93 +85,43 @@ const IntegrationContainer = ({
});
return (
<>
<Collapsible key={app.name} open={showKeys}>
<div className={`${!lastEntry && "border-b"}`}>
<div className="flex w-full flex-1 items-center justify-between space-x-3 p-4 rtl:space-x-reverse md:max-w-3xl">
{app.logo && <img className="h-10 w-10" src={app.logo} alt={app.title} />}
<div className="flex-grow truncate pl-2">
<h3 className="flex truncate text-sm font-medium text-gray-900">
<p>{app.name || app.title}</p>
{app.isTemplate && (
<Badge variant="red" className="ml-4">
Template
</Badge>
<li>
<AppListCard
logo={app.logo}
description={app.description}
title={app.name}
isTemplate={app.isTemplate}
actions={
<div className="flex justify-self-end">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button StartIcon={FiMoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{app.keys && (
<DropdownMenuItem>
<DropdownItem onClick={showKeyModal} type="button" StartIcon={FiEdit}>
{t("edit_keys")}
</DropdownItem>
</DropdownMenuItem>
)}
</h3>
<p className="truncate text-sm text-gray-500">{app.description}</p>
</div>
<div className="flex justify-self-end">
<>
<Switch
checked={app.enabled}
<DropdownMenuItem
onClick={() => {
if (app.enabled) {
setDisableDialog(true);
} else {
enableAppMutation.mutate({ slug: app.slug, enabled: app.enabled });
setShowKeys(true);
}
}}
/>
{app.keys && (
<>
<VerticalDivider className="h-10" />
<CollapsibleTrigger>
<Button
color="secondary"
variant="icon"
tooltip={t("edit_keys")}
onClick={() => setShowKeys(!showKeys)}>
<FiEdit />
</Button>
</CollapsibleTrigger>
</>
)}
</>
</div>
}}>
<DropdownItem StartIcon={app.enabled ? FiXCircle : FiCheckCircle} type="button">
{app.enabled ? t("disable") : t("enable")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
<CollapsibleContent>
{!!app.keys && typeof app.keys === "object" && (
<Form
form={formMethods}
handleSubmit={(values) =>
saveKeysMutation.mutate({
slug: app.slug,
type: app.type,
keys: values,
dirName: app.dirName,
})
}
className="px-4 pb-4">
{Object.keys(app.keys).map((key) => (
<Controller
name={key}
key={key}
control={formMethods.control}
defaultValue={app.keys && app.keys[key] ? app?.keys[key] : ""}
render={({ field: { value } }) => (
<TextField
label={key}
key={key}
name={key}
value={value}
onChange={(e) => {
formMethods.setValue(key, e?.target.value);
}}
/>
)}
/>
))}
<Button type="submit" loading={saveKeysMutation.isLoading}>
{t("save")}
</Button>
</Form>
)}
</CollapsibleContent>
</div>
</Collapsible>
}
/>
<Dialog open={disableDialog} onOpenChange={setDisableDialog}>
<ConfirmationDialogContent
@ -169,7 +133,7 @@ const IntegrationContainer = ({
{t("disable_app_description")}
</ConfirmationDialogContent>
</Dialog>
</>
</li>
);
};
@ -202,7 +166,7 @@ const AdminAppsList = ({
baseURL={baseURL}
fromAdmin
useQueryParam={useQueryParam}
containerClassname="w-full xl:mx-5 xl:w-2/3 xl:pr-5"
containerClassname="min-w-0 w-full"
className={className}>
<AdminAppsListContainer />
</AppCategoryNavigation>
@ -210,15 +174,113 @@ const AdminAppsList = ({
);
};
const EditKeysModal: FC<{
dirName: string;
slug: string;
type: string;
isOpen: boolean;
keys: App["keys"];
handleModelClose: () => void;
}> = (props) => {
const { t } = useLocale();
const { dirName, slug, type, isOpen, keys, handleModelClose } = props;
const appKeySchema = appKeysSchemas[dirName as keyof typeof appKeysSchemas];
const formMethods = useForm({
resolver: zodResolver(appKeySchema),
});
const saveKeysMutation = trpc.viewer.appsRouter.saveKeys.useMutation({
onSuccess: () => {
showToast(t("keys_have_been_saved"), "success");
handleModelClose();
},
onError: (error) => {
showToast(error.message, "error");
},
});
return (
<Dialog open={isOpen} onOpenChange={handleModelClose}>
<DialogContent title={t("edit_keys")} type="creation">
{!!keys && typeof keys === "object" && (
<Form
id="edit-keys"
form={formMethods}
handleSubmit={(values) =>
saveKeysMutation.mutate({
slug,
type,
keys: values,
dirName,
})
}
className="px-4 pb-4">
{Object.keys(keys).map((key) => (
<Controller
name={key}
key={key}
control={formMethods.control}
defaultValue={keys && keys[key] ? keys?.[key] : ""}
render={({ field: { value } }) => (
<TextField
label={key}
key={key}
name={key}
value={value}
onChange={(e) => {
formMethods.setValue(key, e?.target.value);
}}
/>
)}
/>
))}
</Form>
)}
<DialogFooter>
<DialogClose onClick={handleModelClose} />
<Button form="edit-keys" type="submit">
{t("save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
interface EditModalState extends Pick<App, "keys"> {
isOpen: "none" | "editKeys" | "disableKeys";
dirName: string;
type: string;
slug: string;
}
const AdminAppsListContainer = () => {
const { t } = useLocale();
const router = useRouter();
const { category } = querySchema.parse(router.query);
const { data: apps, isLoading } = trpc.viewer.appsRouter.listLocal.useQuery(
{ category },
{ enabled: router.isReady }
);
const [modalState, setModalState] = useReducer(
(data: EditModalState, partialData: Partial<EditModalState>) => ({ ...data, ...partialData }),
{
keys: null,
isOpen: "none",
dirName: "",
type: "",
slug: "",
}
);
const handleModelClose = () =>
setModalState({ keys: null, isOpen: "none", dirName: "", slug: "", type: "" });
const handleModelOpen = (data: EditModalState) => setModalState({ ...data });
if (isLoading) return <SkeletonLoader />;
if (!apps) {
@ -232,16 +294,26 @@ const AdminAppsListContainer = () => {
}
return (
<div className="rounded-md border border-gray-200">
{apps.map((app, index) => (
<IntegrationContainer
app={app}
lastEntry={index === apps.length - 1}
key={app.name}
category={category}
/>
))}
</div>
<>
<List>
{apps.map((app) => (
<IntegrationContainer
handleModelOpen={handleModelOpen}
app={app}
key={app.name}
category={category}
/>
))}
</List>
<EditKeysModal
keys={modalState.keys}
dirName={modalState.dirName}
handleModelClose={handleModelClose}
isOpen={modalState.isOpen === "editKeys"}
slug={modalState.slug}
type={modalState.type}
/>
</>
);
};

View File

@ -0,0 +1,55 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Dialog, DialogContent, showToast, DialogFooter, DialogClose } from "@calcom/ui";
import { FiAlertCircle } from "@calcom/ui/components/icon";
interface DisconnectIntegrationModalProps {
credentialId: number | null;
isOpen: boolean;
handleModelClose: () => void;
}
export default function DisconnectIntegrationModal({
credentialId,
isOpen,
handleModelClose,
}: DisconnectIntegrationModalProps) {
const { t } = useLocale();
const utils = trpc.useContext();
const mutation = trpc.viewer.deleteCredential.useMutation({
onSuccess: () => {
showToast(t("app_removed_successfully"), "success");
handleModelClose();
utils.viewer.integrations.invalidate();
utils.viewer.connectedCalendars.invalidate();
},
onError: () => {
showToast(t("error_removing_app"), "error");
handleModelClose();
},
});
return (
<Dialog open={isOpen} onOpenChange={handleModelClose}>
<DialogContent
title={t("remove_app")}
description={t("are_you_sure_you_want_to_remove_this_app")}
type="confirmation"
Icon={FiAlertCircle}>
<DialogFooter>
<DialogClose onClick={handleModelClose} />
<DialogClose
color="primary"
onClick={() => {
if (credentialId) {
mutation.mutate({ id: credentialId });
}
}}>
{t("yes_remove_app")}
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -128,18 +128,18 @@ export function ButtonOrLink({ href, ...props }: ButtonOrLinkProps) {
}
export const DropdownItem = (props: DropdownItemProps) => {
const { StartIcon, EndIcon } = props;
const { StartIcon, EndIcon, children, color, ...rest } = props;
return (
<ButtonOrLink
{...props}
{...rest}
className={classNames(
"inline-flex w-full items-center px-3 py-2 text-gray-700 hover:text-gray-900",
props.color === "destructive" ? "hover:bg-red-100 hover:text-red-700" : "hover:bg-gray-100"
"inline-flex w-full items-center px-3 py-2 text-gray-700 hover:text-gray-900 disabled:cursor-not-allowed",
color === "destructive" ? "hover:bg-red-100 hover:text-red-700" : "hover:bg-gray-100"
)}>
<>
{StartIcon && <StartIcon className="h-4 w-4" />}
<div className="mx-3 text-sm font-medium leading-5">{props.children}</div>
<div className="mx-3 text-sm font-medium leading-5">{children}</div>
{EndIcon && <EndIcon className="h-4 w-4" />}
</>
</ButtonOrLink>