perf: SSR for /teams (#20824)

* finish

* improve

* refactor

* add revalidations

* /settings/teams is not needed

* refactor

* rename

* redundant type casting
This commit is contained in:
Benny Joo
2025-04-23 15:09:25 +01:00
committed by GitHub
parent e29f6af540
commit b32b31ccdc
23 changed files with 195 additions and 140 deletions
@@ -1,15 +1,12 @@
"use client";
import { TeamsListing } from "@calcom/features/ee/teams/components";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
export const TeamsCTA = () => {
const { t } = useLocale();
const [user] = trpc.viewer.me.get.useSuspenseQuery();
return !user.organizationId || user.organization.isOrgAdmin ? (
return (
<Button
data-testid="new-team-btn"
variant="fab"
@@ -18,11 +15,5 @@ export const TeamsCTA = () => {
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
{t("new")}
</Button>
) : null;
);
};
function Teams() {
return <TeamsListing />;
}
export default Teams;
@@ -0,0 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
export async function revalidateTeamsList() {
revalidatePath("/teams");
}
@@ -0,0 +1,5 @@
import { TeamsListSkeleton } from "./skeleton";
export default function Loading() {
return <TeamsListSkeleton />;
}
@@ -8,7 +8,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import TeamsView, { TeamsCTA } from "~/teams/teams-view";
import { ServerTeamsListing } from "./server-page";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -30,13 +30,11 @@ const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => {
}
const t = await getTranslate();
const { Main, CTA } = await ServerTeamsListing({ searchParams });
return (
<ShellMainAppDir
CTA={<TeamsCTA />}
heading={t("teams")}
subtitle={t("create_manage_teams_collaborative")}>
<TeamsView />
<ShellMainAppDir CTA={CTA} heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
{Main}
</ShellMainAppDir>
);
};
@@ -0,0 +1,54 @@
import { createRouterCaller } from "app/_trpc/context";
import type { SearchParams } from "app/_types";
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
import { CreationSource } from "@calcom/prisma/enums";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { TRPCError } from "@trpc/server";
import { TeamsCTA } from "./CTA";
export const ServerTeamsListing = async ({ searchParams }: { searchParams: SearchParams }) => {
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
const [teamsCaller, meCaller] = await Promise.all([
createRouterCaller(viewerTeamsRouter),
createRouterCaller(meRouter),
]);
let teamNameFromInvite,
errorMsgFromInvite = null;
if (token) {
try {
teamNameFromInvite = await teamsCaller.inviteMemberByToken({
token,
creationSource: CreationSource.WEBAPP,
});
} catch (e) {
errorMsgFromInvite = "Error while fetching teams";
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
}
}
const [user, teams] = await Promise.all([
meCaller.get(),
teamsCaller.list({
includeOrgs: true,
}),
]);
return {
Main: (
<TeamsListing
teams={teams}
user={user}
teamNameFromInvite={teamNameFromInvite ?? null}
errorMsgFromInvite={errorMsgFromInvite}
/>
),
CTA: !user.organizationId || user.organization.isOrgAdmin ? <TeamsCTA /> : null,
};
};
@@ -0,0 +1,19 @@
"use client";
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import { TeamsCTA } from "app/(use-page-wrapper)/(main-nav)/teams/CTA";
import SkeletonLoaderTeamList from "@calcom/features/ee/teams/components/SkeletonloaderTeamList";
import { useLocale } from "@calcom/lib/hooks/useLocale";
export const TeamsListSkeleton = () => {
const { t } = useLocale();
return (
<ShellMainAppDir
heading={t("teams")}
subtitle={t("create_manage_teams_collaborative")}
CTA={<TeamsCTA />}>
<SkeletonLoaderTeamList />
</ShellMainAppDir>
);
};
@@ -1,25 +0,0 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import LegacyPage from "@calcom/features/ee/teams/pages/team-listing-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("teams"),
(t) => t("create_manage_teams_collaborative"),
undefined,
undefined,
"/settings/teams"
);
const Page = async () => {
const t = await getTranslate();
return (
<SettingsHeader title={t("teams")} description={t("create_manage_teams_collaborative")}>
<LegacyPage />
</SettingsHeader>
);
};
export default Page;
+2
View File
@@ -21,6 +21,7 @@ export const POST_METHODS_ALLOWED_APP_ROUTES = [
"/settings/my-account/general",
"/settings/developer/webhooks",
"/settings/developer/api-keys",
"/teams",
];
export function checkPostMethod(req: NextRequest) {
const pathname = req.nextUrl.pathname;
@@ -194,6 +195,7 @@ export const config = {
"/settings/my-account/general",
"/settings/developer/webhooks",
"/settings/developer/api-keys",
"/teams",
],
};
@@ -3,6 +3,7 @@ import { CreateANewTeamForm } from "@calcom/features/ee/teams/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { DialogContent } from "@calcom/ui/components/dialog";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
interface CreateTeamDialogProps {
open: boolean;
@@ -24,6 +25,7 @@ const CreateTeamDialog = (props: CreateTeamDialogProps) => {
onSuccess={async () => {
await utils.viewer.dsync.teamGroupMapping.get.invalidate();
await utils.viewer.teams.list.invalidate();
revalidateTeamsList();
onOpenChange(false);
}}
/>
@@ -15,6 +15,7 @@ import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
import MakeTeamPrivateSwitch from "../../../teams/components/MakeTeamPrivateSwitch";
import MemberListItem from "../components/MemberListItem";
@@ -159,6 +160,7 @@ const MembersView = () => {
utils.viewer.organizations.getMembers.invalidate();
utils.viewer.organizations.listOtherTeams.invalidate();
utils.viewer.teams.list.invalidate();
revalidateTeamsList();
utils.viewer.organizations.listOtherTeamMembers.invalidate();
},
});
@@ -31,6 +31,7 @@ import { TextField } from "@calcom/ui/components/form";
import { ImageUploader } from "@calcom/ui/components/image-uploader";
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
import { subdomainSuffix } from "../../../organizations/lib/orgDomains";
@@ -128,6 +129,7 @@ const OtherTeamProfileView = () => {
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.list.invalidate();
revalidateTeamsList();
await utils.viewer.eventTypes.invalidate();
showToast(t("success"), "success");
},
@@ -1,3 +1,5 @@
"use client";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
@@ -1,3 +1,5 @@
"use client";
import { SkeletonText } from "@calcom/ui/components/skeleton";
function SkeletonLoaderTeamList() {
@@ -4,6 +4,7 @@ import { trackFormbricksAction } from "@calcom/lib/formbricks-client";
import type { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
import TeamInviteListItem from "./TeamInviteListItem";
@@ -36,6 +37,7 @@ export default function TeamInviteList(props: Props) {
const deleteTeamMutation = trpc.viewer.teams.delete.useMutation({
async onSuccess() {
await utils.viewer.teams.list.invalidate();
revalidateTeamsList();
await utils.viewer.teams.get.invalidate();
await utils.viewer.organizations.listMembers.invalidate();
trackFormbricksAction("team_disbanded");
@@ -2,6 +2,9 @@ import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import {
Dropdown,
DropdownItem,
@@ -9,10 +12,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import classNames from "@calcom/ui/classNames";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
interface Props {
team: {
@@ -43,6 +44,7 @@ export default function TeamInviteListItem(props: Props) {
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.hasTeamPlan.invalidate();
await utils.viewer.teams.list.invalidate();
revalidateTeamsList();
await utils.viewer.organizations.listMembers.invalidate();
},
});
@@ -11,11 +11,13 @@ import { trpc } from "@calcom/trpc/react";
import { Card } from "@calcom/ui/components/card";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
import TeamListItem from "./TeamListItem";
interface Props {
teams: RouterOutputs["viewer"]["teams"]["list"];
user: RouterOutputs["viewer"]["me"]["get"];
/**
* True for teams that are pending invite acceptance
*/
@@ -26,7 +28,7 @@ export default function TeamList(props: Props) {
const utils = trpc.useUtils();
const { t } = useLocale();
const { data: user } = trpc.viewer.me.get.useQuery();
const { user } = props;
const [hideDropdown, setHideDropdown] = useState(false);
@@ -41,6 +43,7 @@ export default function TeamList(props: Props) {
const deleteTeamMutation = trpc.viewer.teams.delete.useMutation({
async onSuccess() {
await utils.viewer.teams.list.invalidate();
revalidateTeamsList();
await utils.viewer.teams.hasTeamPlan.invalidate();
trackFormbricksAction("team_disbanded");
},
@@ -113,6 +116,7 @@ export default function TeamList(props: Props) {
<TeamListItem
key={team?.id as number}
team={team}
user={user}
onActionSelect={(action: string) => selectAction(action, team?.id as number)}
isPending={deleteTeamMutation.isPending}
hideDropdown={hideDropdown}
@@ -30,11 +30,13 @@ import {
} from "@calcom/ui/components/dropdown";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
import { TeamRole } from "./TeamPill";
interface Props {
team: RouterOutputs["viewer"]["teams"]["list"][number];
user: RouterOutputs["viewer"]["me"]["get"];
key: number;
onActionSelect: (text: string) => void;
isPending?: boolean;
@@ -46,8 +48,7 @@ export default function TeamListItem(props: Props) {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const utils = trpc.useUtils();
const user = trpc.viewer.me.get.useQuery().data;
const team = props.team;
const { team, user } = props;
const showDialog = searchParams?.get("inviteModal") === "true";
const [openMemberInvitationModal, setOpenMemberInvitationModal] = useState(showDialog);
@@ -59,6 +60,7 @@ export default function TeamListItem(props: Props) {
showToast(t("success"), "success");
utils.viewer.teams.get.invalidate();
utils.viewer.teams.list.invalidate();
revalidateTeamsList();
utils.viewer.teams.hasTeamPlan.invalidate();
utils.viewer.teams.listInvites.invalidate();
const userOrganizationId = user?.profile?.organization?.id;
@@ -1,56 +1,40 @@
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo } from "react";
import SkeletonLoaderTeamList from "@calcom/features/ee/teams/components/SkeletonloaderTeamList";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { CreationSource } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/components/icon";
import { Label } from "@calcom/ui/components/form";
import { Alert } from "@calcom/ui/components/alert";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { Label } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
import { UpgradeTip } from "../../../tips";
import SkeletonLoaderTeamList from "./SkeletonloaderTeamList";
import TeamList from "./TeamList";
export function TeamsListing() {
const searchParams = useCompatSearchParams();
const token = searchParams?.get("token");
type TeamsListingProps = {
user: RouterOutputs["viewer"]["me"]["get"];
teams: RouterOutputs["viewer"]["teams"]["list"];
teamNameFromInvite: string | null;
errorMsgFromInvite: string | null;
};
export function TeamsListing({
user,
teams: data,
teamNameFromInvite,
errorMsgFromInvite,
}: TeamsListingProps) {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const { t } = useLocale();
const trpcContext = trpc.useUtils();
const router = useRouter();
const [inviteTokenChecked, setInviteTokenChecked] = useState(false);
const { data, isPending, error } = trpc.viewer.teams.list.useQuery(
{
includeOrgs: true,
},
{
enabled: inviteTokenChecked,
}
);
const { data: user } = trpc.viewer.me.get.useQuery();
const { mutate: inviteMemberByToken } = trpc.viewer.teams.inviteMemberByToken.useMutation({
onSuccess: (teamName) => {
trpcContext.viewer.teams.list.invalidate();
showToast(t("team_invite_received", { teamName }), "success");
},
onError: (e) => {
showToast(e.message, "error");
},
onSettled: () => {
setInviteTokenChecked(true);
},
});
const teams = useMemo(() => data?.filter((m) => m.accepted && !m.isOrganization) || [], [data]);
const teamInvites = useMemo(() => data?.filter((m) => !m.accepted && !m.isOrganization) || [], [data]);
@@ -101,58 +85,63 @@ export function TeamsListing() {
];
useEffect(() => {
if (!router) return;
if (token) inviteMemberByToken({ token, creationSource: CreationSource.WEBAPP });
else setInviteTokenChecked(true);
}, [router, inviteMemberByToken, setInviteTokenChecked, token]);
if (!token) {
return;
}
if (isPending || !inviteTokenChecked) {
return <SkeletonLoaderTeamList />;
}
if (teamNameFromInvite) {
showToast(t("team_invite_received", { teamName: teamNameFromInvite }), "success");
return;
}
if (errorMsgFromInvite) {
showToast(errorMsgFromInvite, "error");
return;
}
}, []);
return (
<>
{!!error && <Alert severity="error" title={error.message} />}
{organizationInvites.length > 0 && (
<div className="bg-subtle mb-6 rounded-md p-5">
<Label className="text-emphasis pb-2 font-semibold">{t("pending_organization_invites")}</Label>
<TeamList teams={organizationInvites} pending />
<TeamList user={user} teams={organizationInvites} pending />
</div>
)}
{teamInvites.length > 0 && (
<div className="bg-subtle mb-6 rounded-md p-5">
<Label className="text-emphasis pb-2 font-semibold">{t("pending_invites")}</Label>
<TeamList teams={teamInvites} pending />
<TeamList user={user} teams={teamInvites} pending />
</div>
)}
<UpgradeTip
plan="team"
title={t("calcom_is_better_with_team", { appName: APP_NAME })}
description="add_your_team_members"
features={features}
background="/tips/teams"
buttons={
!user?.organizationId || user?.organization.isOrgAdmin ? (
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="minimal" href="https://go.cal.com/teams-video" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
) : (
<p>{t("org_admins_can_create_new_teams")}</p>
)
}>
{teams.length > 0 ? (
<TeamList teams={teams} />
) : (
{teams.length > 0 && <TeamList user={user} teams={teams} />}
{teams.length === 0 && (
<UpgradeTip
plan="team"
title={t("calcom_is_better_with_team", { appName: APP_NAME })}
description={t("add_your_team_members")}
features={features}
background="/tips/teams"
buttons={
!user?.organizationId || user?.organization.isOrgAdmin ? (
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="minimal" href="https://go.cal.com/teams-video" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
) : (
<p>{t("org_admins_can_create_new_teams")}</p>
)
}
isParentLoading={<SkeletonLoaderTeamList />}>
<EmptyScreen
Icon="users"
headline={t("create_team_to_get_started")}
@@ -170,8 +159,8 @@ export function TeamsListing() {
</Button>
}
/>
)}
</UpgradeTip>
</UpgradeTip>
)}
<p className="text-subtle mb-8 mt-4 flex w-full items-center gap-1 text-sm md:justify-center md:text-center">
<Icon className="hidden sm:block" name="info" /> {t("tip_username_plus")}
@@ -1,10 +1,12 @@
"use client";
import { useRouter } from "next/navigation";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { TopBanner } from "@calcom/ui/components/top-banner";
import { showToast } from "@calcom/ui/components/toast";
import { TopBanner } from "@calcom/ui/components/top-banner";
export type TeamsUpgradeBannerProps = {
data: RouterOutputs["viewer"]["me"]["getUserTopBanners"]["teamUpgradeBanner"];
@@ -1,9 +0,0 @@
"use client";
import { TeamsListing } from "../components";
const TeamListingView = () => {
return <TeamsListing />;
};
export default TeamListingView;
@@ -42,6 +42,7 @@ import {
} from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
const regex = new RegExp("^[a-zA-Z0-9-]*$");
@@ -123,6 +124,7 @@ const ProfileView = () => {
const deleteTeamMutation = trpc.viewer.teams.delete.useMutation({
async onSuccess() {
await utils.viewer.teams.list.invalidate();
revalidateTeamsList();
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
await utils.viewer.eventTypes.getByViewer.invalidate();
showToast(t("your_team_disbanded_successfully"), "success");
@@ -138,6 +140,7 @@ const ProfileView = () => {
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.list.invalidate();
revalidateTeamsList();
await utils.viewer.eventTypes.invalidate();
showToast(t("success"), "success");
},
@@ -276,6 +279,7 @@ const TeamProfileForm = ({ team, teamId }: TeamProfileFormProps) => {
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
// TODO: Not all changes require list invalidation
await utils.viewer.teams.list.invalidate();
revalidateTeamsList();
showToast(t("your_team_updated_successfully"), "success");
},
});
@@ -47,7 +47,7 @@ function UpgradeTeamTip() {
<UpgradeTip
plan="team"
title={t("calcom_is_better_with_team", { appName: APP_NAME }) as string}
description="add_your_team_members"
description={t("add_your_team_members")}
background="/tips/teams"
features={[]}
buttons={
+2 -4
View File
@@ -1,7 +1,6 @@
import type { ReactNode } from "react";
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
import { trpc } from "@calcom/trpc";
import classNames from "@calcom/ui/classNames";
@@ -28,7 +27,6 @@ export function UpgradeTip({
plan: "team" | "enterprise";
}) {
const { resolvedTheme } = useGetTheme();
const { t } = useLocale();
const { isPending, hasTeamPlan } = useHasTeamPlan();
const { data } = trpc.viewer.teams.getUpgradeable.useQuery();
const imageSrc = `${background}${resolvedTheme === "dark" ? "-dark" : ""}.jpg`;
@@ -57,8 +55,8 @@ export function UpgradeTip({
/>
</picture>
<div className="relative my-4 px-8 sm:px-14">
<h1 className={classNames("font-cal mt-4 text-3xl")}>{t(title)}</h1>
<p className={classNames("mb-8 mt-4 max-w-sm")}>{t(description)}</p>
<h1 className={classNames("font-cal mt-4 text-3xl")}>{title}</h1>
<p className={classNames("mb-8 mt-4 max-w-sm")}>{description}</p>
{buttons}
</div>
</div>