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 <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
Joe Au-Yeung 2023-03-09 04:07:23 -05:00 committed by GitHub
parent c32aadf297
commit 58b439ca65
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 240 additions and 121 deletions

View File

@ -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<typeof App>[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 = ({
<InstallAppButton
type={type}
isProOnly={isProOnly}
disableInstall={disableInstall}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
@ -176,6 +189,7 @@ const Component = ({
<InstallAppButton
type={type}
isProOnly={isProOnly}
disableInstall={disableInstall}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
@ -203,6 +217,16 @@ const Component = ({
) : (
<SkeletonButton className="h-10 w-24" />
)}
{dependencies &&
(!dependencyData.isLoading ? (
<div className="mt-6">
<AppDependencyComponent appName={name} dependencyData={dependencyData.data} />
</div>
) : (
<SkeletonButton className="mt-6 h-20 grow" />
))}
{price !== 0 && (
<span className="block text-right">
{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 (
<Shell smallHeading isPublic heading={<ShellHeading />} backPath="/apps" withoutSeo>

View File

@ -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<typeof getStaticProps>) {
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" && <ExisitingGoogleCal />}
<div dangerouslySetInnerHTML={{ __html: md.render(source.content) }} />
</>
}

View File

@ -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",

View File

@ -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;
}

View File

@ -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"]
}

View File

@ -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")),

View File

@ -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 <InstallAppButtonComponent render={props.render} onChanged={props.onChanged} />;
return (
<InstallAppButtonComponent
render={props.render}
onChanged={props.onChanged}
disableInstall={props.disableInstall}
/>
);
};
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 (
<div
className={classNames(
"rounded-md py-3 px-4",
dependencyData && dependencyData.some((dependency) => !dependency.installed)
? "bg-blue-100"
: "bg-gray-100"
)}>
{dependencyData &&
dependencyData.map((dependency) => {
return dependency.installed ? (
<div className="items-start space-x-2.5">
<div className="flex items-start">
<div>
<FiCheck className="mt-1 mr-2 font-semibold" />
</div>
<div>
<span className="font-semibold">
{t("app_is_connected", { dependencyName: dependency.name })}
</span>
<div>
<div>
<span>
{t("this_app_requires_connected_account", {
appName,
dependencyName: dependency.name,
})}
</span>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="items-start space-x-2.5">
<div className="flex items-start text-blue-900">
<div>
<FiAlertCircle className="mt-1 mr-2 font-semibold" />
</div>
<div>
<span className="font-semibold">
{t("this_app_requires_connected_account", { appName, dependencyName: dependency.name })}
</span>
<div>
<div>
<>
<Link
href={`${CAL_URL}/apps/${dependency.slug}`}
className="flex items-center text-blue-900 underline">
<span className="mr-1">
{t("connect_app", { dependencyName: dependency.name })}
</span>
<FiArrowRight />
</Link>
</>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
);
};

View File

@ -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 ? (
<div className="rounded-md bg-gray-100 py-3 px-4">
<div className="items-start space-x-2.5">
<div className="flex items-start">
<div>
<FiCheck className="mt-1 mr-2 font-semibold" />
</div>
<div>
<span className="font-semibold">{t("google_calendar_is_connected")}</span>
<div>
<div>
<span>{t("requires_google_calendar")}</span>
</div>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="rounded-md bg-blue-100 py-3 px-4 text-blue-900">
<div className="items-start space-x-2.5">
<div className="flex items-start">
<div>
<FiAlertCircle className="mt-1 mr-2 font-semibold" />
</div>
<div>
<span className="font-semibold">{t("this_app_requires_google_calendar", { appName })}</span>
<div>
<div>
<>
<Link
href={`${CAL_URL}/apps/google-calendar`}
className="flex items-center text-blue-900 underline">
<span className="mr-1">{t("connect_google_calendar")}</span>
<FiArrowRight />
</Link>
</>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ExistingGoogleCal;

View File

@ -31,6 +31,7 @@ export const metadata = {
},
},
dirName: "googlevideo",
dependencies: ["google-calendar"],
} as AppMeta;
export default metadata;

View File

@ -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 (
<div className="rounded-md bg-blue-100 py-3 px-4 text-blue-900">
<div className="flex items-start space-x-2.5">
<div>
<FiAlertCircle className="font-semibold" />
</div>
<div>
<span className="font-semibold">{t("requires_google_calendar")}</span>
<div>
<>
{isLoading ? (
<SkeletonText className="h-4 w-full" />
) : hasGoogleCal ? (
t("connected_google_calendar")
) : (
<Trans i18nKey="no_google_calendar">
Please connect your Google Calendar account{" "}
<Link href={`${CAL_URL}/apps/google-calendar`} className="font-semibold text-blue-900">
here
</Link>
</Trans>
)}
</>
</div>
</div>
</div>
</div>
);
};
export default ExistingGoogleCal;

View File

@ -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,
})}
<WarningDialog open={showWarningDialog} onOpenChange={setShowWarningDialog} />
</>
);
}
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 (
<Dialog name="Account check" open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent
type="creation"
title={t("using_meet_requires_calendar")}
description={googleCalendarPresent ? "" : t("continue_to_install_google_calendar")}>
<DialogFooter>
<>
<DialogClose
type="button"
color="secondary"
tabIndex={-1}
onClick={() => {
props.onOpenChange?.(false);
}}>
{t("cancel")}
</DialogClose>
<Button type="button" onClick={() => mutation.mutate("")}>
{googleCalendarPresent ? t("install_google_meet") : t("install_google_calendar")}
</Button>
</>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1 +0,0 @@
export { default as InstallAppButton } from "./InstallAppButton";

View File

@ -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"]
}

View File

@ -24,6 +24,7 @@ export interface InstallAppButtonProps {
}
) => JSX.Element;
onChanged?: () => unknown;
disableInstall?: boolean;
}
export type EventTypeAppCardComponentProps = {
// Limit what data should be accessible to apps

View File

@ -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"]
}

View File

@ -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;
}),
});

View File

@ -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<App, "key"> & {
/** We should type error if keys are leaked to the frontend */
isDefault?: boolean;
key?: never;
dependencyData?: {
name?: string;
installed?: boolean;
}[];
};
export type AppMeta = Optional<App, "rating" | "trending" | "reviews" | "verified">;

View File

@ -88,6 +88,7 @@ export function AppCard({ app, credentials, searchText }: AppCardProps) {
<InstallAppButton
type={app.type}
isProOnly={app.isProOnly}
disableInstall={!!app.dependencies && !app.dependencyData?.some((data) => !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 (