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:
parent
4f9aa8bd96
commit
e121615d36
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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") {
|
||||
if (credentialId || type === "stripe_payment" || isGlobal) {
|
||||
return (
|
||||
<DisconnectIntegration
|
||||
credentialId={credentialId}
|
||||
trashIcon
|
||||
onSuccess={handleOpenChange}
|
||||
buttonProps={{ className: "border border-gray-300" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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} />}
|
||||
{categoryList.includes(category) && (
|
||||
<IntegrationsContainer handleDisconnect={handleDisconnect} variant={category} />
|
||||
)}
|
||||
{category === "calendar" && <CalendarListContainer />}
|
||||
{category === "other" && (
|
||||
<IntegrationsContainer variant={category} exclude={[...categoryList, "calendar"]} />
|
||||
<IntegrationsContainer
|
||||
handleDisconnect={handleDisconnect}
|
||||
variant={category}
|
||||
exclude={[...categoryList, "calendar"]}
|
||||
/>
|
||||
)}
|
||||
</InstalledAppsLayout>
|
||||
<DisconnectIntegrationModal
|
||||
handleModelClose={handleModelClose}
|
||||
isOpen={data.isOpen}
|
||||
credentialId={data.credentialId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</h3>
|
||||
<p className="truncate text-sm text-gray-500">{app.description}</p>
|
||||
</div>
|
||||
<li>
|
||||
<AppListCard
|
||||
logo={app.logo}
|
||||
description={app.description}
|
||||
title={app.name}
|
||||
isTemplate={app.isTemplate}
|
||||
actions={
|
||||
<div className="flex justify-self-end">
|
||||
<>
|
||||
<Switch
|
||||
checked={app.enabled}
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}}>
|
||||
<DropdownItem StartIcon={app.enabled ? FiXCircle : FiCheckCircle} type="button">
|
||||
{app.enabled ? t("disable") : t("enable")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</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) => (
|
||||
<>
|
||||
<List>
|
||||
{apps.map((app) => (
|
||||
<IntegrationContainer
|
||||
handleModelOpen={handleModelOpen}
|
||||
app={app}
|
||||
lastEntry={index === apps.length - 1}
|
||||
key={app.name}
|
||||
category={category}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</List>
|
||||
<EditKeysModal
|
||||
keys={modalState.keys}
|
||||
dirName={modalState.dirName}
|
||||
handleModelClose={handleModelClose}
|
||||
isOpen={modalState.isOpen === "editKeys"}
|
||||
slug={modalState.slug}
|
||||
type={modalState.type}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue
Block a user