Perf: Optimize event-types page (#2436)

* Perf: Optimize event-types page

* Memoize layout in Shell

* setQueryState without awaiting mutate for optimistic update

* Update Shell.tsx

* Fix types

* Update auth-index.test.ts

Co-authored-by: zomars <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Afzal Sayed 2022-04-15 03:19:51 +05:30 committed by GitHub
parent 3c6ac395cc
commit 699d910ab4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 203 additions and 129 deletions

View file

@ -1,23 +1,25 @@
import { SelectorIcon } from "@heroicons/react/outline";
import {
ArrowLeftIcon,
CalendarIcon,
ClockIcon,
CogIcon,
ExternalLinkIcon,
LinkIcon,
LogoutIcon,
ViewGridIcon,
MoonIcon,
MapIcon,
ArrowLeftIcon,
MoonIcon,
ViewGridIcon,
} from "@heroicons/react/solid";
import { signOut, useSession } from "next-auth/react";
import { SessionContextValue, signOut, useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { Fragment, ReactNode, useEffect } from "react";
import { Toaster } from "react-hot-toast";
import { useIsEmbed } from "@calcom/embed-core";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { UserPlan } from "@calcom/prisma/client";
import Button from "@calcom/ui/Button";
import Dropdown, {
DropdownMenuContent,
@ -30,9 +32,8 @@ import TrialBanner from "@ee/components/TrialBanner";
import HelpMenuItem from "@ee/components/support/HelpMenuItem";
import classNames from "@lib/classNames";
import { NEXT_PUBLIC_BASE_URL } from "@lib/config/constants";
import { WEBAPP_URL } from "@lib/config/constants";
import { shouldShowOnboarding } from "@lib/getting-started";
import { useLocale } from "@lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { trpc } from "@lib/trpc";
@ -58,7 +59,6 @@ function useRedirectToLoginIfUnauthenticated(isPublic = false) {
const { data: session, status } = useSession();
const loading = status === "loading";
const router = useRouter();
const shouldDisplayUnauthed = router.pathname.startsWith("/apps");
useEffect(() => {
if (isPublic) {
@ -69,7 +69,7 @@ function useRedirectToLoginIfUnauthenticated(isPublic = false) {
router.replace({
pathname: "/auth/login",
query: {
callbackUrl: `${NEXT_PUBLIC_BASE_URL}/${location.pathname}${location.search}`,
callbackUrl: `${WEBAPP_URL}/${location.pathname}${location.search}`,
},
});
}
@ -78,7 +78,6 @@ function useRedirectToLoginIfUnauthenticated(isPublic = false) {
return {
loading: loading && !session,
shouldDisplayUnauthed,
session,
};
}
@ -122,28 +121,14 @@ export function ShellSubHeading(props: {
);
}
export default function Shell(props: {
centered?: boolean;
title?: string;
heading?: ReactNode;
subtitle?: ReactNode;
children: ReactNode;
CTA?: ReactNode;
large?: boolean;
HeadingLeftIcon?: ReactNode;
backPath?: string; // renders back button to specified path
// use when content needs to expand with flex
flexChildrenContainer?: boolean;
isPublic?: boolean;
}) {
const Layout = ({
status,
plan,
...props
}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan }) => {
const isEmbed = useIsEmbed();
const { t } = useLocale();
const router = useRouter();
const { loading, session } = useRedirectToLoginIfUnauthenticated(props.isPublic);
const { isRedirectingToOnboarding } = useRedirectToOnboardingIfNeeded();
const telemetry = useTelemetry();
const { t } = useLocale();
const navigation = [
{
name: t("event_types_page_title"),
@ -188,35 +173,10 @@ export default function Shell(props: {
current: router.asPath.startsWith("/settings"),
},
];
useEffect(() => {
telemetry.withJitsu((jitsu) => {
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.asPath));
});
}, [telemetry, router.asPath]);
const pageTitle = typeof props.heading === "string" ? props.heading : props.title;
const query = useMeQuery();
const user = query.data;
const i18n = useViewerI18n();
const { status } = useSession();
if (i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading) {
// show spinner whilst i18n is loading to avoid language flicker
return (
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-50">
<Loader />
</div>
);
}
if (!session && !props.isPublic) return null;
return (
<>
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
<HeadSeo
title={pageTitle ?? "Cal.com"}
description={props.subtitle ? props.subtitle?.toString() : ""}
@ -306,8 +266,8 @@ export default function Shell(props: {
<small style={{ fontSize: "0.5rem" }} className="mx-3 mt-1 mb-2 hidden opacity-50 lg:block">
&copy; {new Date().getFullYear()} Cal.com, Inc. v.{pkg.version + "-"}
{process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com" ? "h" : "sh"}
<span className="lowercase" data-testid={`plan-${user?.plan.toLowerCase()}`}>
-{user && user.plan}
<span className="lowercase" data-testid={`plan-${plan?.toLowerCase()}`}>
-{plan}
</span>
</small>
</div>
@ -426,6 +386,60 @@ export default function Shell(props: {
</div>
</>
);
};
const MemoizedLayout = React.memo(Layout);
type LayoutProps = {
centered?: boolean;
title?: string;
heading?: ReactNode;
subtitle?: ReactNode;
children: ReactNode;
CTA?: ReactNode;
large?: boolean;
HeadingLeftIcon?: ReactNode;
backPath?: string; // renders back button to specified path
// use when content needs to expand with flex
flexChildrenContainer?: boolean;
isPublic?: boolean;
};
export default function Shell(props: LayoutProps) {
const router = useRouter();
const { loading, session } = useRedirectToLoginIfUnauthenticated(props.isPublic);
const { isRedirectingToOnboarding } = useRedirectToOnboardingIfNeeded();
const telemetry = useTelemetry();
useEffect(() => {
telemetry.withJitsu((jitsu) => {
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.asPath));
});
}, [telemetry, router.asPath]);
const query = useMeQuery();
const user = query.data;
const i18n = useViewerI18n();
const { status } = useSession();
if (i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading) {
// show spinner whilst i18n is loading to avoid language flicker
return (
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-50">
<Loader />
</div>
);
}
if (!session && !props.isPublic) return null;
return (
<>
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
<MemoizedLayout plan={user?.plan} status={status} {...props} />
</>
);
}
function UserDropdown({ small }: { small?: boolean }) {

View file

@ -9,8 +9,21 @@ import {
import { Alert } from "@calcom/ui/Alert";
import { trpc } from "@lib/trpc";
import Loader from "@components/Loader";
import type { AppRouter } from "@server/routers/_app";
import type { TRPCClientErrorLike } from "@trpc/client";
import type { UseTRPCQueryOptions } from "@trpc/react";
// import type { inferProcedures } from "@trpc/react/src/createReactQueryHooks";
import type {
inferHandlerInput,
inferProcedureInput,
inferProcedureOutput,
ProcedureRecord,
} from "@trpc/server";
type ErrorLike = {
message: string;
};
@ -72,3 +85,31 @@ export function QueryCell<TData, TError extends ErrorLike>(
// impossible state
return null;
}
type inferProcedures<TObj extends ProcedureRecord<any, any, any, any, any, any>> = {
[TPath in keyof TObj]: {
input: inferProcedureInput<TObj[TPath]>;
output: inferProcedureOutput<TObj[TPath]>;
};
};
type TQueryValues = inferProcedures<AppRouter["_def"]["queries"]>;
type TQueries = AppRouter["_def"]["queries"];
type TError = TRPCClientErrorLike<AppRouter>;
const withQuery = <TPath extends keyof TQueryValues & string>(
pathAndInput: [path: TPath, ...args: inferHandlerInput<TQueries[TPath]>],
params?: UseTRPCQueryOptions<TPath, TQueryValues[TPath]["input"], TQueryValues[TPath]["output"], TError>
) => {
return function WithQuery(
opts: Omit<
Partial<QueryCellOptionsWithEmpty<TQueryValues[TPath]["output"], TError>> &
QueryCellOptionsNoEmpty<TQueryValues[TPath]["output"], TError>,
"query"
>
) {
const query = trpc.useQuery(pathAndInput, params);
return <QueryCell query={query} {...opts} />;
};
};
export { withQuery };

View file

@ -9,7 +9,7 @@ import showToast from "@calcom/lib/notification";
import { Button } from "@calcom/ui";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
import { QueryCell } from "@lib/QueryCell";
import { withQuery } from "@lib/QueryCell";
import { HttpError } from "@lib/core/http/error";
import { inferQueryOutput, trpc } from "@lib/trpc";
@ -99,13 +99,14 @@ export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availab
);
}
const WithQuery = withQuery(["viewer.availability.list"]);
export default function AvailabilityPage() {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.availability.list"]);
return (
<div>
<Shell heading={t("availability")} subtitle={t("configure_availability")} CTA={<NewScheduleButton />}>
<QueryCell query={query} success={({ data }) => <AvailabilityList {...data} />} />
<WithQuery success={({ data }) => <AvailabilityList {...data} />} />
</Shell>
</div>
);

View file

@ -30,7 +30,7 @@ import Dropdown, {
DropdownMenuSeparator,
} from "@calcom/ui/Dropdown";
import { QueryCell } from "@lib/QueryCell";
import { withQuery } from "@lib/QueryCell";
import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error";
import { inferQueryOutput, trpc } from "@lib/trpc";
@ -63,38 +63,83 @@ type EventTypeGroup = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"][n
type EventType = EventTypeGroup["eventTypes"][number];
interface EventTypeListProps {
group: EventTypeGroup;
groupIndex: number;
readOnly: boolean;
types: EventType[];
}
export const EventTypeList = ({ group, readOnly, types }: EventTypeListProps): JSX.Element => {
const Item = ({ type, group, readOnly }: any) => {
const { t } = useLocale();
return (
<Link href={"/event-types/" + type.id}>
<a
className="flex-grow truncate text-sm"
title={`${type.title} ${type.description ? ` ${type.description}` : ""}`}>
<div>
<span
className="truncate font-medium text-neutral-900 ltr:mr-1 rtl:ml-1"
data-testid={"event-type-title-" + type.id}>
{type.title}
</span>
<small
className="hidden text-neutral-500 sm:inline"
data-testid={"event-type-slug-" + type.id}>{`/${group.profile.slug}/${type.slug}`}</small>
{type.hidden && (
<span className="rtl:mr-2inline items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800 ltr:ml-2">
{t("hidden")}
</span>
)}
{readOnly && (
<span className="rtl:mr-2inline items-center rounded-sm bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-800 ltr:ml-2">
{t("readonly")}
</span>
)}
</div>
<EventTypeDescription eventType={type} />
</a>
</Link>
);
};
const MemoizedItem = React.memo(Item);
export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeListProps): JSX.Element => {
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const mutation = trpc.useMutation("viewer.eventTypeOrder", {
onError: (err) => {
onError: async (err) => {
console.error(err.message);
},
async onSettled() {
await utils.cancelQuery(["viewer.eventTypes"]);
await utils.invalidateQueries(["viewer.eventTypes"]);
},
});
const [sortableTypes, setSortableTypes] = useState(types);
useEffect(() => {
setSortableTypes(types);
}, [types]);
function moveEventType(index: number, increment: 1 | -1) {
const newList = [...sortableTypes];
const type = sortableTypes[index];
const tmp = sortableTypes[index + increment];
function moveEventType(index: number, increment: 1 | -1) {
const newList = [...types];
const type = types[index];
const tmp = types[index + increment];
if (tmp) {
newList[index] = tmp;
newList[index + increment] = type;
}
setSortableTypes(newList);
utils.cancelQuery(["viewer.eventTypes"]);
utils.setQueryData(["viewer.eventTypes"], (data) =>
Object.assign(data, {
eventTypesGroups: [
data?.eventTypeGroups.slice(0, groupIndex),
Object.assign(group, {
eventTypes: newList,
}),
data?.eventTypeGroups.slice(groupIndex + 1),
],
})
);
mutation.mutate({
ids: newList.map((type) => type.id),
});
@ -155,7 +200,7 @@ export const EventTypeList = ({ group, readOnly, types }: EventTypeListProps): J
return (
<div className="-mx-4 mb-16 overflow-hidden rounded-sm border border-gray-200 bg-white sm:mx-0">
<ul className="divide-y divide-neutral-200" data-testid="event-types">
{sortableTypes.map((type, index) => (
{types.map((type, index) => (
<li
key={type.id}
className={classNames(
@ -168,7 +213,7 @@ export const EventTypeList = ({ group, readOnly, types }: EventTypeListProps): J
type.$disabled && "pointer-events-none"
)}>
<div className="group flex w-full items-center justify-between px-4 py-4 hover:bg-neutral-50 sm:px-6">
{sortableTypes.length > 1 && (
{types.length > 1 && (
<>
<button
className="invisible absolute left-1/2 -mt-4 mb-4 -ml-4 hidden h-7 w-7 scale-0 rounded-full border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:left-[19px] sm:ml-0 sm:block"
@ -183,36 +228,7 @@ export const EventTypeList = ({ group, readOnly, types }: EventTypeListProps): J
</button>
</>
)}
<Link href={"/event-types/" + type.id}>
<a
className="flex-grow truncate text-sm"
title={`${type.title} ${type.description ? ` ${type.description}` : ""}`}>
<div>
<span
className="truncate font-medium text-neutral-900 ltr:mr-1 rtl:ml-1"
data-testid={"event-type-title-" + type.id}>
{type.title}
</span>
<small
className="hidden text-neutral-500 sm:inline"
data-testid={
"event-type-slug-" + type.id
}>{`/${group.profile.slug}/${type.slug}`}</small>
{type.hidden && (
<span className="rtl:mr-2inline items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800 ltr:ml-2">
{t("hidden")}
</span>
)}
{readOnly && (
<span className="rtl:mr-2inline items-center rounded-sm bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-800 ltr:ml-2">
{t("readonly")}
</span>
)}
</div>
<EventTypeDescription eventType={type} />
</a>
</Link>
<MemoizedItem type={type} group={group} readOnly={readOnly} />
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 sm:flex">
<div className="flex justify-between rtl:space-x-reverse">
{type.users?.length > 1 && (
@ -487,9 +503,19 @@ const CreateFirstEventTypeView = ({ canAddEvents, profiles }: CreateEventTypePro
);
};
const CTA = () => {
const query = trpc.useQuery(["viewer.eventTypes"]);
if (!query.data) return null;
return (
<CreateEventTypeButton canAddEvents={query.data.viewer.canAddEvents} options={query.data.profiles} />
);
};
const WithQuery = withQuery(["viewer.eventTypes"]);
const EventTypesPage = () => {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.eventTypes"]);
return (
<div>
@ -497,19 +523,8 @@ const EventTypesPage = () => {
<title>Home | Cal.com</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell
heading={t("event_types_page_title")}
subtitle={t("event_types_page_subtitle")}
CTA={
query.data && (
<CreateEventTypeButton
canAddEvents={query.data.viewer.canAddEvents}
options={query.data.profiles}
/>
)
}>
<QueryCell
query={query}
<Shell heading={t("event_types_page_title")} subtitle={t("event_types_page_subtitle")} CTA={<CTA />}>
<WithQuery
success={({ data }) => (
<>
{data.viewer.plan === "FREE" && !data.viewer.canAddEvents && (
@ -528,7 +543,7 @@ const EventTypesPage = () => {
className="mb-4"
/>
)}
{data.eventTypeGroups.map((group) => (
{data.eventTypeGroups.map((group, index) => (
<Fragment key={group.profile.slug}>
{/* hide list heading when there is only one (current user) */}
{(data.eventTypeGroups.length !== 1 || group.teamId) && (
@ -537,7 +552,12 @@ const EventTypesPage = () => {
membershipCount={group.metadata.membershipCount}
/>
)}
<EventTypeList types={group.eventTypes} group={group} readOnly={group.metadata.readOnly} />
<EventTypeList
types={group.eventTypes}
group={group}
groupIndex={index}
readOnly={group.metadata.readOnly}
/>
</Fragment>
))}

View file

@ -11,7 +11,7 @@ import Button from "@calcom/ui/Button";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { TextField } from "@calcom/ui/form/fields";
import { QueryCell } from "@lib/QueryCell";
import { withQuery } from "@lib/QueryCell";
import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { nameOfDay } from "@lib/core/i18n/weekday";
@ -482,17 +482,15 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
);
}
const WithQuery = withQuery(["viewer.i18n"]);
export default function Settings(props: Props) {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.i18n"]);
return (
<Shell heading={t("profile")} subtitle={t("edit_profile_info_description")}>
<SettingsShell>
<QueryCell
query={query}
success={({ data }) => <SettingsView {...props} localeProp={data.locale} />}
/>
<WithQuery success={({ data }) => <SettingsView {...props} localeProp={data.locale} />} />
</SettingsShell>
</Shell>
);

View file

@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
import { BASE_URL } from "@lib/config/constants";
import { WEBAPP_URL } from "@lib/config/constants";
import prisma from "@lib/prisma";
import { todo } from "../lib/testUtils";
@ -40,7 +40,7 @@ test.describe("Can signup from a team invite", async () => {
select: { token: true },
});
token = tokenObj?.token;
signupFromInviteURL = `/auth/signup?token=${token}&callbackUrl=${BASE_URL}/settings/teams`;
signupFromInviteURL = `/auth/signup?token=${token}&callbackUrl=${WEBAPP_URL}/settings/teams`;
});
test.afterAll(async () => {