Feat/custom default video (#6971)

* Add queries to fetch metadata and update default

* Add to zod schema

* Change default rules on settings page

* Update query

* Initall override on create event type

* Working default

* Change schema to save locationLink too

* Update default conferncing to include link

* Rename tsx to ts. Fix metadata keys

* WIP on refactoring appstore installed

* Add this to appstore installed apps too

* Display only on conferencing apps

* Add i18n strings

* Change order in dropdown menu so delete is bottom

* Refactor query to not expose all of metadata

* Fix mr type checker

* Possible e2e fix

* Refactor metadata to use ctx

* Refactor back to query cell

* Alias schema

* Adding comment for more info

* Remove constant for clarity

* Update nextjs-bundle-analysis.yml

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
sean-brydon
2023-02-13 22:55:32 +00:00
committed by GitHub
co-authored by Alex van Andel zomars
parent 6cad606ba1
commit 77600f8168
7 changed files with 377 additions and 153 deletions
+149 -96
View File
@@ -1,10 +1,12 @@
import { useRouter } from "next/router";
import { useReducer } from "react";
import { useReducer, useState } from "react";
import z from "zod";
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
import { InstallAppButton } from "@calcom/app-store/components";
import { EventLocationType, getEventLocationTypeFromApp } from "@calcom/app-store/locations";
import { InstalledAppVariants } from "@calcom/app-store/utils";
import { AppSetDefaultLinkDailog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RouterOutputs, trpc } from "@calcom/trpc/react";
@@ -22,6 +24,7 @@ import {
Dropdown,
DropdownMenuItem,
DropdownItem,
showToast,
} from "@calcom/ui";
import {
FiBarChart,
@@ -41,7 +44,7 @@ import AppListCard from "@components/AppListCard";
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
function ConnectOrDisconnectIntegrationButton(props: {
function ConnectOrDisconnectIntegrationMenuItem(props: {
credentialIds: number[];
type: App["type"];
isGlobal?: boolean;
@@ -60,22 +63,15 @@ function ConnectOrDisconnectIntegrationButton(props: {
if (credentialId || type === "stripe_payment" || isGlobal) {
return (
<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>
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => handleDisconnect(credentialId)}
disabled={isGlobal}
StartIcon={FiTrash}>
{t("remove_app")}
</DropdownItem>
</DropdownMenuItem>
);
}
@@ -112,37 +108,95 @@ interface IntegrationsListProps {
handleDisconnect: (credentialId: number) => void;
}
const IntegrationsList = ({ data, handleDisconnect }: IntegrationsListProps) => {
const IntegrationsList = ({ data, handleDisconnect, variant }: IntegrationsListProps) => {
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
const utils = trpc.useContext();
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
undefined
);
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
onSuccess: () => {
showToast("Default app updated successfully", "success");
utils.viewer.getUsersDefaultConferencingApp.invalidate();
},
});
const { t } = useLocale();
return (
<List>
{data.items
.filter((item) => item.invalidCredentialIds)
.map((item) => (
<AppListCard
key={item.name}
description={item.description}
title={item.name}
logo={item.logo}
isDefault={item.isGlobal}
shouldHighlight
slug={item.slug}
invalidCredential={item.invalidCredentialIds.length > 0}
actions={
<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} />
</AppListCard>
))}
</List>
<>
<List>
{data.items
.filter((item) => item.invalidCredentialIds)
.map((item) => {
const appSlug = item?.slug;
const appIsDefault =
appSlug === defaultConferencingApp?.appSlug ||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug);
return (
<AppListCard
key={item.name}
description={item.description}
title={item.name}
logo={item.logo}
isDefault={appIsDefault}
shouldHighlight
slug={item.slug}
invalidCredential={item.invalidCredentialIds.length > 0}
actions={
<div className="flex justify-end">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button StartIcon={FiMoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{!appIsDefault && variant === "conferencing" && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="secondary"
StartIcon={FiVideo}
onClick={() => {
const locationType = getEventLocationTypeFromApp(
item?.locationOption?.value ?? ""
);
if (locationType?.linkType === "static") {
setLocationType({ ...locationType, slug: appSlug });
} else {
updateDefaultAppMutation.mutate({
appSlug,
});
}
}}>
{t("change_default_conferencing_app")}
</DropdownItem>
</DropdownMenuItem>
)}
<ConnectOrDisconnectIntegrationMenuItem
credentialIds={item.credentialIds}
type={item.type}
isGlobal={item.isGlobal}
installed
invalidCredentialIds={item.invalidCredentialIds}
handleDisconnect={handleDisconnect}
/>
</DropdownMenuContent>
</Dropdown>
</div>
}>
<AppSettings slug={item.slug} />
</AppListCard>
);
})}
</List>
{locationType && (
<AppSetDefaultLinkDailog
locationType={locationType}
setLocationType={() => setLocationType(undefined)}
/>
)}
</>
);
};
@@ -168,46 +222,43 @@ const IntegrationsContainer = ({
query={query}
customLoader={<SkeletonLoader />}
success={({ data }) => {
if (!data.items.length) {
return (
<EmptyScreen
Icon={emptyIcon[variant || "other"]}
headline={t("no_category_apps", {
category: (variant && t(variant).toLowerCase()) || t("other").toLowerCase(),
})}
description={t(`no_category_apps_description_${variant || "other"}`)}
buttonRaw={
<Button
color="secondary"
data-testid={`connect-${variant || "other"}-apps`}
href={variant ? `/apps/categories/${variant}` : "/apps/categories/other"}>
{t(`connect_${variant || "other"}_apps`)}
</Button>
}
/>
);
}
return (
<>
{data.items.length > 0 ? (
<div className="rounded-md border border-gray-200 p-7">
<ShellSubHeading
title={t(variant || "other")}
subtitle={t(`installed_app_${variant || "other"}_description`)}
className="mb-6"
actions={
<Button
href={
variant
? `/apps/categories/${variant === "conferencing" ? "video" : variant}`
: "/apps"
}
color="secondary"
StartIcon={FiPlus}>
{t("add")}
</Button>
<ShellSubHeading
title={t(variant || "other")}
subtitle={t(`installed_app_${variant || "other"}_description`)}
className="mb-6"
actions={
<Button
href={
variant ? `/apps/categories/${variant === "conferencing" ? "video" : variant}` : "/apps"
}
/>
<IntegrationsList handleDisconnect={handleDisconnect} data={data} variant={variant} />
</div>
) : (
<EmptyScreen
Icon={emptyIcon[variant || "other"]}
headline={t("no_category_apps", {
category: (variant && t(variant).toLowerCase()) || t("other").toLowerCase(),
})}
description={t(`no_category_apps_description_${variant || "other"}`)}
buttonRaw={
<Button
color="secondary"
data-testid={`connect-${variant || "other"}-apps`}
href={variant ? `/apps/categories/${variant}` : "/apps/categories/other"}>
{t(`connect_${variant || "other"}_apps`)}
</Button>
}
/>
)}
color="secondary"
StartIcon={FiPlus}>
{t("add")}
</Button>
}
/>
<IntegrationsList handleDisconnect={handleDisconnect} data={data} variant={variant} />
</>
);
}}
@@ -257,17 +308,19 @@ export default function InstalledApps() {
return (
<>
<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"]}
/>
)}
<div className="rounded-md border border-gray-200 p-7">
{categoryList.includes(category) && (
<IntegrationsContainer handleDisconnect={handleDisconnect} variant={category} />
)}
{category === "calendar" && <CalendarListContainer />}
{category === "other" && (
<IntegrationsContainer
handleDisconnect={handleDisconnect}
variant={category}
exclude={[...categoryList, "calendar"]}
/>
)}
</div>
</InstalledAppsLayout>
<DisconnectIntegrationModal
handleModelClose={handleModelClose}
@@ -1,6 +1,7 @@
import { GetServerSidePropsContext } from "next";
import { useState } from "react";
import { EventLocationType, getEventLocationTypeFromApp } from "@calcom/app-store/locations";
import { AppSetDefaultLinkDailog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
@@ -21,12 +22,10 @@ import {
SkeletonContainer,
SkeletonText,
} from "@calcom/ui";
import { FiAlertCircle, FiMoreHorizontal, FiTrash } from "@calcom/ui/components/icon";
import { FiAlertCircle, FiMoreHorizontal, FiTrash, FiVideo } from "@calcom/ui/components/icon";
import AppListCard from "@components/AppListCard";
import { ssrInit } from "@server/lib/ssr";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
@@ -43,12 +42,13 @@ const ConferencingLayout = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const { data: apps, isLoading } = trpc.viewer.integrations.useQuery(
{ variant: "conferencing", onlyInstalled: true },
{
suspense: true,
}
);
const { data: defaultConferencingApp, isLoading: defaultConferencingAppLoading } =
trpc.viewer.getUsersDefaultConferencingApp.useQuery();
const { data: apps, isLoading } = trpc.viewer.integrations.useQuery({
variant: "conferencing",
onlyInstalled: true,
});
const deleteAppMutation = trpc.viewer.deleteCredential.useMutation({
onSuccess: () => {
showToast("Integration deleted successfully", "success");
@@ -61,10 +61,20 @@ const ConferencingLayout = () => {
},
});
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
onSuccess: () => {
showToast("Default app updated successfully", "success");
utils.viewer.getUsersDefaultConferencingApp.invalidate();
},
});
const [deleteAppModal, setDeleteAppModal] = useState(false);
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
undefined
);
const [deleteCredentialId, setDeleteCredentialId] = useState<number>(0);
if (isLoading)
if (isLoading || defaultConferencingAppLoading)
return <SkeletonLoader title={t("conferencing")} description={t("conferencing_description")} />;
return (
@@ -74,39 +84,67 @@ const ConferencingLayout = () => {
{apps?.items &&
apps.items
.map((app) => ({ ...app, title: app.title || app.name }))
.map((app) => (
<AppListCard
description={app.description}
title={app.title}
logo={app.logo}
key={app.title}
isDefault={app.isGlobal}
actions={
<div>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button StartIcon={FiMoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
disabled={app.isGlobal}
StartIcon={FiTrash}
onClick={() => {
setDeleteCredentialId(app.credentialIds[0]);
setDeleteAppModal(true);
}}>
{t("remove_app")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
}
/>
))}
.map((app) => {
const appSlug = app?.slug;
const appIsDefault =
appSlug === defaultConferencingApp?.appSlug ||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug); // Default to cal video if the user doesnt have it set (we do this on new account creation but not old)
return (
<AppListCard
description={app.description}
title={app.title}
logo={app.logo}
key={app.title}
isDefault={appIsDefault} // @TODO: Handle when a user doesnt have this value set
actions={
<div>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button StartIcon={FiMoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{!appIsDefault && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="secondary"
StartIcon={FiVideo}
onClick={() => {
const locationType = getEventLocationTypeFromApp(
app?.locationOption?.value ?? ""
);
if (locationType?.linkType === "static") {
setLocationType({ ...locationType, slug: appSlug });
} else {
updateDefaultAppMutation.mutate({
appSlug,
});
}
}}>
{t("change_default_conferencing_app")}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
disabled={app.isGlobal}
StartIcon={FiTrash}
onClick={() => {
setDeleteCredentialId(app.credentialIds[0]);
setDeleteAppModal(true);
}}>
{t("remove_app")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
}
/>
);
})}
</List>
<Dialog open={deleteAppModal} onOpenChange={setDeleteAppModal}>
@@ -123,20 +161,14 @@ const ConferencingLayout = () => {
</DialogFooter>
</DialogContent>
</Dialog>
{locationType && (
<AppSetDefaultLinkDailog locationType={locationType} setLocationType={setLocationType} />
)}
</div>
);
};
ConferencingLayout.getLayout = getLayout;
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
return {
props: {
trpcState: ssr.dehydrate(),
},
};
};
export default ConferencingLayout;
@@ -1589,6 +1589,9 @@
"create_a_one_one_template": "Create a one-one one template for an event type and distribute it to multiple members.",
"collective_or_roundrobin": "Collective or round-robin",
"book_your_team_members": "Book your team members together with collective events or cycle through to get the right person with round-robin.",
"default_app_link_title":"Set a default app link",
"default_app_link_description":"Setting a default app link allows all newly created event types to use the app link you set.",
"change_default_conferencing_app":"Set as default",
"under_maintenance": "Down for maintenance",
"under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.",
"event_type_seats": "{{numberOfSeats}} seats",
@@ -0,0 +1,79 @@
import { Dispatch, SetStateAction } from "react";
import { useForm } from "react-hook-form";
import { EventLocationType } from "@calcom/app-store/locations";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
showToast,
Dialog,
DialogContent,
Form,
TextField,
DialogFooter,
Button,
DialogClose,
} from "@calcom/ui";
import { FiAlertCircle } from "@calcom/ui/components/icon";
type LocationTypeSetLinkDialogFormProps = {
link?: string;
type: EventLocationType["type"];
};
export function AppSetDefaultLinkDailog({
locationType,
setLocationType,
}: {
locationType: EventLocationType & { slug: string };
setLocationType: Dispatch<SetStateAction<(EventLocationType & { slug: string }) | undefined>>;
}) {
const utils = trpc.useContext();
const { t } = useLocale();
const form = useForm<LocationTypeSetLinkDialogFormProps>({});
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
onSuccess: () => {
showToast("Default app updated successfully", "success");
utils.viewer.getUsersDefaultConferencingApp.invalidate();
},
});
return (
<Dialog open={!!locationType} onOpenChange={() => setLocationType(undefined)}>
<DialogContent
title={t("default_app_link_title")}
description={t("default_app_link_description")}
type="creation"
Icon={FiAlertCircle}>
<Form
form={form}
handleSubmit={(values) => {
updateDefaultAppMutation.mutate({
appSlug: locationType.slug,
appLink: values.link,
});
setLocationType(undefined);
}}>
<>
<TextField
type="text"
required
{...form.register("link")}
placeholder={locationType.organizerInputPlaceholder ?? ""}
label={locationType.label ?? ""}
/>
<DialogFooter>
<Button color="primary" type="submit">
{t("save")}
</Button>
<DialogClose />
</DialogFooter>
</>
</Form>
</DialogContent>
</Dialog>
);
}
+6
View File
@@ -209,6 +209,12 @@ export const userMetadata = z
vitalSettings: vitalSettingsUpdateSchema.optional(),
isPremium: z.boolean().optional(),
sessionTimeout: z.number().optional(), // Minutes
defaultConferencingApp: z
.object({
appSlug: z.string().default("daily-video").optional(),
appLink: z.string().optional(),
})
.optional(),
})
.nullable();
+29 -1
View File
@@ -472,7 +472,6 @@ const loggedInViewerRouter = router({
const { credentials } = user;
const enabledApps = await getEnabledApps(credentials);
let apps = enabledApps.map(
({ credentials: _, credential: _1 /* don't leak to frontend */, ...app }) => {
const credentialIds = credentials.filter((c) => c.type === app.type).map((c) => c.id);
@@ -1181,6 +1180,35 @@ const loggedInViewerRouter = router({
});
}
}),
getUsersDefaultConferencingApp: authedProcedure.query(async ({ ctx }) => {
return userMetadata.parse(ctx.user.metadata)?.defaultConferencingApp;
}),
updateUserDefaultConferencingApp: authedProcedure
.input(
z.object({
appSlug: z.string().optional(),
appLink: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const currentMetadata = userMetadata.parse(ctx.user.metadata);
await ctx.prisma.user.update({
where: {
id: ctx.user.id,
},
data: {
metadata: {
...currentMetadata,
defaultConferencingApp: {
appSlug: input.appSlug,
appLink: input.appLink,
},
},
},
});
return input;
}),
});
export const viewerRouter = mergeRouters(
@@ -7,6 +7,7 @@ import { z } from "zod";
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
import { DailyLocationType } from "@calcom/app-store/locations";
import { stripeDataSchema } from "@calcom/app-store/stripepayment/lib/server";
import getApps from "@calcom/app-store/utils";
import { validateBookingLimitOrder } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import getEventTypeById from "@calcom/lib/getEventTypeById";
@@ -17,6 +18,7 @@ import {
CustomInputSchema,
EventTypeMetaDataSchema,
stringOrNumber,
userMetadata as userMetadataSchema,
} from "@calcom/prisma/zod-utils";
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
@@ -406,7 +408,29 @@ export const eventTypesRouter = router({
create: authedProcedure.input(createEventTypeInput).mutation(async ({ ctx, input }) => {
const { schedulingType, teamId, ...rest } = input;
const userId = ctx.user.id;
// Get Users default conferncing app
const defaultConferencingData = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp;
const appKeys = await getAppKeysFromSlug("daily-video");
let locations: { type: string; link?: string }[] = [];
// If no locations are passed in and the user has a daily api key then default to daily
if (
(typeof rest?.locations === "undefined" || rest.locations?.length === 0) &&
typeof appKeys.api_key === "string"
) {
locations = [{ type: DailyLocationType }];
}
// If its defaulting to daily no point handling compute as its done
if (defaultConferencingData && defaultConferencingData.appSlug !== "daily-video") {
const credentials = ctx.user.credentials;
const foundApp = getApps(credentials).filter((app) => app.slug === defaultConferencingData.appSlug)[0]; // There is only one possible install here so index [0] is the one we are looking for ;
const locationType = foundApp?.locationOption?.value ?? DailyLocationType; // Default to Daily if no location type is found
locations = [{ type: locationType, link: defaultConferencingData.appLink }];
}
const data: Prisma.EventTypeCreateInput = {
...rest,
owner: teamId ? undefined : { connect: { id: userId } },
@@ -415,8 +439,7 @@ export const eventTypesRouter = router({
id: userId,
},
},
...((typeof rest?.locations === "undefined" || rest.locations?.length === 0) &&
typeof appKeys.api_key === "string" && { locations: [{ type: DailyLocationType }] }),
locations,
};
if (teamId && schedulingType) {