diff --git a/apps/web/components/AppListCard.tsx b/apps/web/components/AppListCard.tsx index 286a95ba49..84ef80f856 100644 --- a/apps/web/components/AppListCard.tsx +++ b/apps/web/components/AppListCard.tsx @@ -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(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 ( -
-
+
+
{logo ? {`${title} : null}

{title}

- {isDefault ? {t("default")} : null} +
+ {isDefault && {t("default")}} + {isTemplate && Template} +
{description} + {invalidCredential && ( +
+ + + {t("invalid_credential")} + +
+ )}
{actions}
+ {children &&
{children}
}
); } diff --git a/apps/web/components/apps/CalendarListContainer.tsx b/apps/web/components/apps/CalendarListContainer.tsx index d8534c80d6..e716c3816b 100644 --- a/apps/web/components/apps/CalendarListContainer.tsx +++ b/apps/web/components/apps/CalendarListContainer.tsx @@ -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 }) => ( {data.items.map((item) => ( - + {data.connectedCalendars.map((item) => ( {item.calendars ? ( - )}
- + ) : ( { - const timer = setTimeout(() => setHighlight(false), 3000); - return () => { - clearTimeout(timer); - }; - }, []); - return ( - -
- {props.logo && {title}} -
- - {props.name || title} - {props.isTemplate && ( - - Template - - )} - - {props.description} - {/* Alert error that key stopped working. */} - {props.invalidCredential && ( -
- - - {t("invalid_credential")} - -
- )} -
-
{props.actions}
-
- {props.children &&
{props.children}
} -
- ); -} - -export default IntegrationListItem; diff --git a/apps/web/components/apps/layouts/InstalledAppsLayout.tsx b/apps/web/components/apps/layouts/InstalledAppsLayout.tsx index 98092eb066..69a13aa7e1 100644 --- a/apps/web/components/apps/layouts/InstalledAppsLayout.tsx +++ b/apps/web/components/apps/layouts/InstalledAppsLayout.tsx @@ -57,9 +57,7 @@ export default function InstalledAppsLayout({ return ( - + {children} diff --git a/apps/web/components/auth/layouts/AdminLayout.tsx b/apps/web/components/auth/layouts/AdminLayout.tsx index cb2ece4eb3..efa0aeb733 100644 --- a/apps/web/components/auth/layouts/AdminLayout.tsx +++ b/apps/web/components/auth/layouts/AdminLayout.tsx @@ -10,6 +10,7 @@ import { UserPermissionRole } from ".prisma/client"; export default function AdminLayout({ children, + ...rest }: { children: React.ReactNode } & ComponentProps) { const session = useSession(); @@ -22,10 +23,11 @@ export default function AdminLayout({ } }, [session, router]); + const isAppsPage = router.asPath.startsWith("/settings/admin/apps"); return (
-
+
*]:flex-1"}> {children}
diff --git a/apps/web/pages/apps/installed/[category].tsx b/apps/web/pages/apps/installed/[category].tsx index a03a1c28d6..5de4cf4b4a 100644 --- a/apps/web/pages/apps/installed/[category].tsx +++ b/apps/web/pages/apps/installed/[category].tsx @@ -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 ( - - ); - } - + if (credentialId || type === "stripe_payment" || isGlobal) { return ( - + + +
); } - /** We don't need to "Connect", just show that it's installed */ - if (isGlobal) { - return ( -
-

{t("default")}

-
- ); - } + return ( void; } interface IntegrationsListProps { variant?: IntegrationsContainerProps["variant"]; data: RouterOutputs["viewer"]["integrations"]; + handleDisconnect: (credentialId: number) => void; } -const IntegrationsList = ({ data }: IntegrationsListProps) => { +const IntegrationsList = ({ data, handleDisconnect }: IntegrationsListProps) => { return ( - + {data.items .filter((item) => item.invalidCredentialIds) .map((item) => ( - 0} actions={ -
+
}> - + ))} ); }; -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) } /> - +
) : ( ; +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) => ({ ...data, ...partialData }), + { + isOpen: false, + credentialId: null, + } + ); + + const handleModelClose = () => { + updateData({ isOpen: false, credentialId: null }); + }; + + const handleDisconnect = (credentialId: number) => { + updateData({ isOpen: true, credentialId }); + }; + return ( - - {categoryList.includes(category) && } - {category === "calendar" && } - {category === "other" && ( - - )} - + <> + + {categoryList.includes(category) && ( + + )} + {category === "calendar" && } + {category === "other" && ( + + )} + + + ); } diff --git a/packages/app-store/_components/AppCategoryNavigation.tsx b/packages/app-store/_components/AppCategoryNavigation.tsx index 73879bff65..1b8b3202bc 100644 --- a/packages/app-store/_components/AppCategoryNavigation.tsx +++ b/packages/app-store/_components/AppCategoryNavigation.tsx @@ -25,7 +25,7 @@ const AppCategoryNavigation = ({ const appCategories = useMemo(() => getAppCategories(baseURL, useQueryParam), [baseURL, useQueryParam]); return ( -
+
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 ( - <> - -
-
- {app.logo && {app.title}} -
-

-

{app.name || app.title}

- {app.isTemplate && ( - - Template - +
  • + + + +
  • -

    {app.description}

    -
    -
    - <> - { if (app.enabled) { setDisableDialog(true); } else { enableAppMutation.mutate({ slug: app.slug, enabled: app.enabled }); - setShowKeys(true); } - }} - /> - {app.keys && ( - <> - - - - - - - )} - -
    + }}> + + {app.enabled ? t("disable") : t("enable")} + + + +
    - - {!!app.keys && typeof app.keys === "object" && ( -
    - saveKeysMutation.mutate({ - slug: app.slug, - type: app.type, - keys: values, - dirName: app.dirName, - }) - } - className="px-4 pb-4"> - {Object.keys(app.keys).map((key) => ( - ( - { - formMethods.setValue(key, e?.target.value); - }} - /> - )} - /> - ))} - - - )} -
    -
    -
    + } + /> - + ); }; @@ -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}> @@ -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 ( + + + {!!keys && typeof keys === "object" && ( +
    + saveKeysMutation.mutate({ + slug, + type, + keys: values, + dirName, + }) + } + className="px-4 pb-4"> + {Object.keys(keys).map((key) => ( + ( + { + formMethods.setValue(key, e?.target.value); + }} + /> + )} + /> + ))} + + )} + + + + +
    +
    + ); +}; + +interface EditModalState extends Pick { + 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) => ({ ...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 ; if (!apps) { @@ -232,16 +294,26 @@ const AdminAppsListContainer = () => { } return ( -
    - {apps.map((app, index) => ( - - ))} -
    + <> + + {apps.map((app) => ( + + ))} + + + ); }; diff --git a/packages/features/apps/components/DisconnectIntegrationModal.tsx b/packages/features/apps/components/DisconnectIntegrationModal.tsx new file mode 100644 index 0000000000..cc5f788a8f --- /dev/null +++ b/packages/features/apps/components/DisconnectIntegrationModal.tsx @@ -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 ( + + + + + { + if (credentialId) { + mutation.mutate({ id: credentialId }); + } + }}> + {t("yes_remove_app")} + + + + + ); +} diff --git a/packages/ui/components/form/dropdown/Dropdown.tsx b/packages/ui/components/form/dropdown/Dropdown.tsx index 86e0692dd2..c3eca28ca6 100644 --- a/packages/ui/components/form/dropdown/Dropdown.tsx +++ b/packages/ui/components/form/dropdown/Dropdown.tsx @@ -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 ( <> {StartIcon && } -
    {props.children}
    +
    {children}
    {EndIcon && }