From 58b439ca650da95b2dd3d04c5f375ad453329ce1 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Thu, 9 Mar 2023 04:07:23 -0500 Subject: [PATCH] Revamp Google Cal warning for Meet, Amie, and Vimcal (#7308) * Create requires Google Cal component * Create installed GCal message * Move requires GCal component to App * Clean up * Abstract prerequisite component * Add requires message on app card * Refactor to dependency * Clean up * Change typeform dep & remove app card dep component * Clean up * Change dependency to dependencies * Pass disableInstall to default install button for AppCard * Refactor app page to dependencies * Type fix * More type fixes * Update apps/web/components/apps/App.tsx * Apply suggestions from code review --------- Co-authored-by: Peer Richelsen Co-authored-by: Hariom Balhara --- apps/web/components/apps/App.tsx | 28 +++++- apps/web/pages/apps/[slug]/index.tsx | 3 +- apps/web/public/static/locales/en/common.json | 4 + packages/app-store/_appRegistry.ts | 18 ++++ packages/app-store/amie/config.json | 3 +- packages/app-store/apps.browser.generated.tsx | 1 - packages/app-store/components.tsx | 94 ++++++++++++++++++- .../components/ExistingGoogleCal.tsx | 56 +++++++++++ packages/app-store/googlevideo/_metadata.ts | 1 + .../components/ExistingGoogleCal.tsx | 44 --------- .../components/InstallAppButton.tsx | 64 ------------- .../app-store/googlevideo/components/index.ts | 1 - packages/app-store/typeform/config.json | 3 +- packages/app-store/types.d.ts | 1 + packages/app-store/vimcal/config.json | 3 +- packages/trpc/server/routers/viewer/apps.tsx | 24 ++++- packages/types/App.d.ts | 10 +- packages/ui/components/apps/AppCard.tsx | 3 + 18 files changed, 240 insertions(+), 121 deletions(-) create mode 100644 packages/app-store/googlecalendar/components/ExistingGoogleCal.tsx delete mode 100644 packages/app-store/googlevideo/components/ExistingGoogleCal.tsx delete mode 100644 packages/app-store/googlevideo/components/InstallAppButton.tsx delete mode 100644 packages/app-store/googlevideo/components/index.ts diff --git a/apps/web/components/apps/App.tsx b/apps/web/components/apps/App.tsx index faa13acbf3..4e049d5a5f 100644 --- a/apps/web/components/apps/App.tsx +++ b/apps/web/components/apps/App.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import React, { useState } from "react"; import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; -import { InstallAppButton } from "@calcom/app-store/components"; +import { InstallAppButton, AppDependencyComponent } from "@calcom/app-store/components"; import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration"; import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired"; import Shell from "@calcom/features/shell/Shell"; @@ -24,6 +24,8 @@ import { FiShield, } from "@calcom/ui/components/icon"; +/* These app slugs all require Google Cal to be installed */ + const Component = ({ name, type, @@ -45,6 +47,7 @@ const Component = ({ isProOnly, images, isTemplate, + dependencies, }: Parameters[0]) => { const { t } = useLocale(); const hasImages = images && images.length > 0; @@ -76,6 +79,15 @@ const Component = ({ } ); + const dependencyData = trpc.viewer.appsRouter.queryForDependencies.useQuery(dependencies, { + enabled: !!dependencies, + }); + + const disableInstall = + dependencyData.data && dependencyData.data.some((dependency) => !dependency.installed); + + // const disableInstall = requiresGCal && !gCalInstalled.data; + // variant not other allows, an app to be shown in calendar category without requiring an actual calendar connection e.g. vimcal // Such apps, can only be installed once. const allowedMultipleInstalls = categories.indexOf("calendar") > -1 && variant !== "other"; @@ -137,6 +149,7 @@ const Component = ({ { if (useDefaultComponent) { props = { @@ -176,6 +189,7 @@ const Component = ({ { if (useDefaultComponent) { props = { @@ -203,6 +217,16 @@ const Component = ({ ) : ( )} + + {dependencies && + (!dependencyData.isLoading ? ( +
+ +
+ ) : ( + + ))} + {price !== 0 && ( {feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar} @@ -332,6 +356,8 @@ export default function App(props: { isProOnly: AppType["isProOnly"]; images?: string[]; isTemplate?: boolean; + disableInstall?: boolean; + dependencies?: string[]; }) { return ( } backPath="/apps" withoutSeo> diff --git a/apps/web/pages/apps/[slug]/index.tsx b/apps/web/pages/apps/[slug]/index.tsx index fc6398cc1b..566f0a8418 100644 --- a/apps/web/pages/apps/[slug]/index.tsx +++ b/apps/web/pages/apps/[slug]/index.tsx @@ -5,7 +5,6 @@ import type { GetStaticPaths, GetStaticPropsContext } from "next"; import path from "path"; import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; -import ExisitingGoogleCal from "@calcom/app-store/googlevideo/components/ExistingGoogleCal"; import prisma from "@calcom/prisma"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; @@ -36,11 +35,11 @@ function SingleAppPage({ data, source }: inferSSRProps) { isProOnly={data.isProOnly} images={source.data?.items as string[] | undefined} isTemplate={data.isTemplate} + dependencies={data.dependencies} // tos="https://zoom.us/terms" // privacy="https://zoom.us/privacy" body={ <> - {data.slug === "google-meet" && }
} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index e1c63f6122..c4f56d10d8 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1635,6 +1635,10 @@ "add_a_new_route": "Add a new Route", "no_responses_yet": "No responses yet", "this_will_be_the_placeholder": "This will be the placeholder", + "this_app_requires_connected_account": "{{appName}} requires a connected {{dependencyName}} account", + "connect_app": "Connect {{dependencyName}}", + "app_is_connected": "{{dependencyName}} is connected", + "requires_app": "Requires {{dependencyName}}", "verification_code": "Verification code", "verify": "Verify", "select_all": "Select All", diff --git a/packages/app-store/_appRegistry.ts b/packages/app-store/_appRegistry.ts index d1a2103b34..a50eefdb87 100644 --- a/packages/app-store/_appRegistry.ts +++ b/packages/app-store/_appRegistry.ts @@ -1,4 +1,5 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { getAppFromSlug } from "@calcom/app-store/utils"; import prisma, { safeAppSelect, safeCredentialSelect } from "@calcom/prisma"; import { userMetadata } from "@calcom/prisma/zod-utils"; import type { AppFrontendPayload as App } from "@calcom/types/App"; @@ -82,6 +83,21 @@ export async function getAppRegistryWithCredentials(userId: number) { // Skip if app isn't installed /* This is now handled from the DB */ // if (!app.installed) return apps; + let dependencyData: { + name?: string; + installed?: boolean; + }[] = []; + if (app.dependencies) { + dependencyData = app.dependencies.map((dependency) => { + const dependencyInstalled = dbApps.some( + (dbAppIterator) => dbAppIterator.credentials.length && dbAppIterator.slug === dependency + ); + // If the app marked as dependency is simply deleted from the codebase, we can have the situation where App is marked installed in DB but we couldn't get the app. + const dependencyName = getAppFromSlug(dependency)?.name; + return { name: dependencyName, installed: dependencyInstalled }; + }); + } + const { rating, reviews, trending, verified, ...remainingAppProps } = app; apps.push({ rating: rating || 0, @@ -93,7 +109,9 @@ export async function getAppRegistryWithCredentials(userId: number) { credentials: dbapp.credentials, installed: true, isDefault: usersDefaultApp === dbapp.slug, + ...(app.dependencies && { dependencyData }), }); } + return apps; } diff --git a/packages/app-store/amie/config.json b/packages/app-store/amie/config.json index e1b4575706..512bcadeca 100644 --- a/packages/app-store/amie/config.json +++ b/packages/app-store/amie/config.json @@ -11,5 +11,6 @@ "publisher": "Cal.com, Inc.", "email": "support@cal.com", "description": "The joyful productivity app\r\r", - "__createdUsingCli": true + "__createdUsingCli": true, + "dependencies": ["google-calendar"] } diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx index b7a37eec88..686a311b5e 100644 --- a/packages/app-store/apps.browser.generated.tsx +++ b/packages/app-store/apps.browser.generated.tsx @@ -13,7 +13,6 @@ export const InstallAppButtonMap = { exchange2016calendar: dynamic(() => import("./exchange2016calendar/components/InstallAppButton")), exchangecalendar: dynamic(() => import("./exchangecalendar/components/InstallAppButton")), googlecalendar: dynamic(() => import("./googlecalendar/components/InstallAppButton")), - googlevideo: dynamic(() => import("./googlevideo/components/InstallAppButton")), hubspot: dynamic(() => import("./hubspot/components/InstallAppButton")), huddle01video: dynamic(() => import("./huddle01video/components/InstallAppButton")), jitsivideo: dynamic(() => import("./jitsivideo/components/InstallAppButton")), diff --git a/packages/app-store/components.tsx b/packages/app-store/components.tsx index 932f51d343..d2e88d4f6b 100644 --- a/packages/app-store/components.tsx +++ b/packages/app-store/components.tsx @@ -1,10 +1,16 @@ +import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect, useRef } from "react"; +import classNames from "@calcom/lib/classNames"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { CAL_URL } from "@calcom/lib/constants"; import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; import type { App } from "@calcom/types/App"; +import { FiAlertCircle, FiArrowRight, FiCheck } from "@calcom/ui/components/icon"; import { InstallAppButtonMap } from "./apps.browser.generated"; import type { InstallAppButtonProps } from "./types"; @@ -16,9 +22,16 @@ export const InstallAppButtonWithoutPlanCheck = ( ) => { const key = deriveAppDictKeyFromType(props.type, InstallAppButtonMap); const InstallAppButtonComponent = InstallAppButtonMap[key as keyof typeof InstallAppButtonMap]; - if (!InstallAppButtonComponent) return <>{props.render({ useDefaultComponent: true })}; + if (!InstallAppButtonComponent) + return <>{props.render({ useDefaultComponent: true, disabled: props.disableInstall })}; - return ; + return ( + + ); }; export const InstallAppButton = ( @@ -26,6 +39,7 @@ export const InstallAppButton = ( isProOnly?: App["isProOnly"]; type: App["type"]; wrapperClassName?: string; + disableInstall?: boolean; } & InstallAppButtonProps ) => { const { isLoading, data: user } = trpc.viewer.me.useQuery(); @@ -63,3 +77,79 @@ export const InstallAppButton = ( }; export { AppConfiguration } from "./_components/AppConfiguration"; + +export const AppDependencyComponent = ({ + appName, + dependencyData, +}: { + appName: string; + dependencyData: RouterOutputs["viewer"]["appsRouter"]["queryForDependencies"]; +}) => { + const { t } = useLocale(); + + return ( +
!dependency.installed) + ? "bg-blue-100" + : "bg-gray-100" + )}> + {dependencyData && + dependencyData.map((dependency) => { + return dependency.installed ? ( +
+
+
+ +
+
+ + {t("app_is_connected", { dependencyName: dependency.name })} + +
+
+ + {t("this_app_requires_connected_account", { + appName, + dependencyName: dependency.name, + })} + +
+
+
+
+
+ ) : ( +
+
+
+ +
+
+ + {t("this_app_requires_connected_account", { appName, dependencyName: dependency.name })} + + +
+
+ <> + + + {t("connect_app", { dependencyName: dependency.name })} + + + + +
+
+
+
+
+ ); + })} +
+ ); +}; diff --git a/packages/app-store/googlecalendar/components/ExistingGoogleCal.tsx b/packages/app-store/googlecalendar/components/ExistingGoogleCal.tsx new file mode 100644 index 0000000000..d6edc878b3 --- /dev/null +++ b/packages/app-store/googlecalendar/components/ExistingGoogleCal.tsx @@ -0,0 +1,56 @@ +import Link from "next/link"; + +import { CAL_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { FiAlertCircle, FiArrowRight, FiCheck } from "@calcom/ui/components/icon"; + +const ExistingGoogleCal = ({ gCalInstalled, appName }: { gCalInstalled?: boolean; appName: string }) => { + const { t } = useLocale(); + + return gCalInstalled ? ( +
+
+
+
+ +
+
+ {t("google_calendar_is_connected")} +
+
+ {t("requires_google_calendar")} +
+
+
+
+
+
+ ) : ( +
+
+
+
+ +
+
+ {t("this_app_requires_google_calendar", { appName })} +
+
+ <> + + {t("connect_google_calendar")} + + + +
+
+
+
+
+
+ ); +}; + +export default ExistingGoogleCal; diff --git a/packages/app-store/googlevideo/_metadata.ts b/packages/app-store/googlevideo/_metadata.ts index 07becf2e80..affd302965 100644 --- a/packages/app-store/googlevideo/_metadata.ts +++ b/packages/app-store/googlevideo/_metadata.ts @@ -31,6 +31,7 @@ export const metadata = { }, }, dirName: "googlevideo", + dependencies: ["google-calendar"], } as AppMeta; export default metadata; diff --git a/packages/app-store/googlevideo/components/ExistingGoogleCal.tsx b/packages/app-store/googlevideo/components/ExistingGoogleCal.tsx deleted file mode 100644 index b85d973560..0000000000 --- a/packages/app-store/googlevideo/components/ExistingGoogleCal.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Trans } from "next-i18next"; -import Link from "next/link"; - -import { CAL_URL } from "@calcom/lib/constants"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { trpc } from "@calcom/trpc"; -import { SkeletonText } from "@calcom/ui"; -import { FiAlertCircle } from "@calcom/ui/components/icon"; - -const ExistingGoogleCal = () => { - const { t } = useLocale(); - const { isLoading, data: hasGoogleCal } = trpc.viewer.appsRouter.checkForGCal.useQuery(); - - return ( -
-
-
- -
-
- {t("requires_google_calendar")} -
- <> - {isLoading ? ( - - ) : hasGoogleCal ? ( - t("connected_google_calendar") - ) : ( - - Please connect your Google Calendar account{" "} - - here - - - )} - -
-
-
-
- ); -}; - -export default ExistingGoogleCal; diff --git a/packages/app-store/googlevideo/components/InstallAppButton.tsx b/packages/app-store/googlevideo/components/InstallAppButton.tsx deleted file mode 100644 index 8e40c4d96b..0000000000 --- a/packages/app-store/googlevideo/components/InstallAppButton.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useState } from "react"; - -import type { InstallAppButtonProps } from "@calcom/app-store/types"; -import useApp from "@calcom/lib/hooks/useApp"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import type { DialogProps } from "@calcom/ui"; -import { Button } from "@calcom/ui"; -import { Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui"; - -import useAddAppMutation from "../../_utils/useAddAppMutation"; - -export default function InstallAppButton(props: InstallAppButtonProps) { - const [showWarningDialog, setShowWarningDialog] = useState(false); - - return ( - <> - {props.render({ - onClick() { - setShowWarningDialog(true); - // mutation.mutate(""); - }, - disabled: showWarningDialog, - })} - - - ); -} - -function WarningDialog(props: DialogProps) { - const { t } = useLocale(); - const googleCalendarData = useApp("google-calendar"); - const googleCalendarPresent = googleCalendarData.data?.isInstalled; - - const mutation = useAddAppMutation(googleCalendarPresent ? "google_video" : "google_calendar", { - installGoogleVideo: !googleCalendarPresent, - }); - - return ( - - - - <> - { - props.onOpenChange?.(false); - }}> - {t("cancel")} - - - - - - - - ); -} diff --git a/packages/app-store/googlevideo/components/index.ts b/packages/app-store/googlevideo/components/index.ts deleted file mode 100644 index 0d6008d4ca..0000000000 --- a/packages/app-store/googlevideo/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as InstallAppButton } from "./InstallAppButton"; diff --git a/packages/app-store/typeform/config.json b/packages/app-store/typeform/config.json index 017590a7a4..4b7ade942a 100644 --- a/packages/app-store/typeform/config.json +++ b/packages/app-store/typeform/config.json @@ -10,5 +10,6 @@ "publisher": "Cal.com", "email": "help@cal.com", "description": "Adds a link to copy Typeform Redirect URL", - "__createdUsingCli": true + "__createdUsingCli": true, + "dependencies": ["routing-forms"] } diff --git a/packages/app-store/types.d.ts b/packages/app-store/types.d.ts index ed44f5f0c6..2a590a5433 100644 --- a/packages/app-store/types.d.ts +++ b/packages/app-store/types.d.ts @@ -24,6 +24,7 @@ export interface InstallAppButtonProps { } ) => JSX.Element; onChanged?: () => unknown; + disableInstall?: boolean; } export type EventTypeAppCardComponentProps = { // Limit what data should be accessible to apps diff --git a/packages/app-store/vimcal/config.json b/packages/app-store/vimcal/config.json index 7ea2953c5e..be6ae60f88 100644 --- a/packages/app-store/vimcal/config.json +++ b/packages/app-store/vimcal/config.json @@ -11,5 +11,6 @@ "publisher": "Cal.com, Inc.", "email": "support@cal.com", "description": "The world's fastest calendar, beautifully designed for a remote world\r", - "__createdUsingCli": true + "__createdUsingCli": true, + "dependencies": ["google-calendar"] } diff --git a/packages/trpc/server/routers/viewer/apps.tsx b/packages/trpc/server/routers/viewer/apps.tsx index d16eabc103..5455663b4c 100644 --- a/packages/trpc/server/routers/viewer/apps.tsx +++ b/packages/trpc/server/routers/viewer/apps.tsx @@ -3,7 +3,7 @@ import type { Prisma } from "@prisma/client"; import z from "zod"; import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated"; -import { getLocalAppMetadata } from "@calcom/app-store/utils"; +import { getLocalAppMetadata, getAppFromSlug } from "@calcom/app-store/utils"; import { sendDisabledAppEmail } from "@calcom/emails"; import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType"; import { getTranslation } from "@calcom/lib/server/i18n"; @@ -320,4 +320,26 @@ export const appsRouter = router({ return !!updated; }), + queryForDependencies: authedProcedure.input(z.string().array().optional()).query(async ({ ctx, input }) => { + if (!input) return; + + const dependencyData: { name: string; slug: string; installed: boolean }[] = []; + + await Promise.all( + input.map(async (dependency) => { + const appInstalled = await ctx.prisma.credential.findFirst({ + where: { + appId: dependency, + userId: ctx.user.id, + }, + }); + + const app = await getAppFromSlug(dependency); + + dependencyData.push({ name: app?.name || dependency, slug: dependency, installed: !!appInstalled }); + }) + ); + + return dependencyData; + }), }); diff --git a/packages/types/App.d.ts b/packages/types/App.d.ts index 6c0c2bda98..79fba3c729 100644 --- a/packages/types/App.d.ts +++ b/packages/types/App.d.ts @@ -1,8 +1,8 @@ import type { Prisma } from "@prisma/client"; -import { Tag } from "@calcom/app-store/types"; +import type { Tag } from "@calcom/app-store/types"; -import { Optional } from "./utils"; +import type { Optional } from "./utils"; type CommonProperties = { default?: false; @@ -138,12 +138,18 @@ export interface App { dirName?: string; isTemplate?: boolean; __template?: string; + /** Slug of an app needed to be installed before the current app can be added */ + dependencies?: string[]; } export type AppFrontendPayload = Omit & { /** We should type error if keys are leaked to the frontend */ isDefault?: boolean; key?: never; + dependencyData?: { + name?: string; + installed?: boolean; + }[]; }; export type AppMeta = Optional; diff --git a/packages/ui/components/apps/AppCard.tsx b/packages/ui/components/apps/AppCard.tsx index d32b3eaa58..d35f5201b2 100644 --- a/packages/ui/components/apps/AppCard.tsx +++ b/packages/ui/components/apps/AppCard.tsx @@ -88,6 +88,7 @@ export function AppCard({ app, credentials, searchText }: AppCardProps) { !data.installed)} wrapperClassName="[@media(max-width:260px)]:w-full" render={({ useDefaultComponent, ...props }) => { if (useDefaultComponent) { @@ -116,6 +117,7 @@ export function AppCard({ app, credentials, searchText }: AppCardProps) { type={app.type} isProOnly={app.isProOnly} wrapperClassName="[@media(max-width:260px)]:w-full" + disableInstall={!!app.dependencies && app.dependencyData?.some((data) => !data.installed)} render={({ useDefaultComponent, ...props }) => { if (useDefaultComponent) { props = { @@ -123,6 +125,7 @@ export function AppCard({ app, credentials, searchText }: AppCardProps) { onClick: () => { mutation.mutate({ type: app.type, variant: app.variant, slug: app.slug }); }, + disabled: !!props.disabled, }; } return (