From c21f0c2d4997107483463556aa7f249fb34c4dab Mon Sep 17 00:00:00 2001 From: Jamie Pine <32987599+jamiepine@users.noreply.github.com> Date: Thu, 16 Dec 2021 16:16:59 -0800 Subject: [PATCH] Even Better Teams (#1304) - dropdown improvements - Improve performance of team availability - Fix default timezone - Allow team admins to edit event types - Change team availability slot input to dropdown select (15,30,60) - Prevent teams from access if not pro user --- components/Shell.tsx | 2 +- components/team/MemberListItem.tsx | 24 +++++++---- components/team/TeamListItem.tsx | 18 ++++++-- components/team/TeamSettings.tsx | 2 +- components/team/TeamSettingsRightSidebar.tsx | 3 +- components/ui/Alert.tsx | 2 +- .../availability/TeamAvailabilityModal.tsx | 33 +++++++------- .../availability/TeamAvailabilityScreen.tsx | 43 +++++++++---------- .../availability/TeamAvailabilityTimes.tsx | 10 ++--- ee/pages/settings/teams/[id]/availability.tsx | 33 ++++++++++---- pages/api/availability/eventtype.ts | 2 +- pages/settings/teams.tsx | 30 +++++++++++-- public/static/locales/en/common.json | 1 + server/createContext.ts | 1 + server/routers/viewer.tsx | 6 ++- server/routers/viewer/teams.tsx | 4 ++ 16 files changed, 141 insertions(+), 73 deletions(-) diff --git a/components/Shell.tsx b/components/Shell.tsx index 714c5c2d..3b86d37c 100644 --- a/components/Shell.tsx +++ b/components/Shell.tsx @@ -39,7 +39,7 @@ import { useViewerI18n } from "./I18nLanguageHandler"; import Logo from "./Logo"; import Button from "./ui/Button"; -function useMeQuery() { +export function useMeQuery() { const meQuery = trpc.useQuery(["viewer.me"], { retry(failureCount) { return failureCount > 3; diff --git a/components/team/MemberListItem.tsx b/components/team/MemberListItem.tsx index 512a0464..35e55ce2 100644 --- a/components/team/MemberListItem.tsx +++ b/components/team/MemberListItem.tsx @@ -17,7 +17,12 @@ import Avatar from "@components/ui/Avatar"; import Button from "@components/ui/Button"; import ModalContainer from "@components/ui/ModalContainer"; -import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown"; +import Dropdown, { + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/Dropdown"; import MemberChangeRoleModal from "./MemberChangeRoleModal"; import TeamRole from "./TeamRole"; import { MembershipRole } from ".prisma/client"; @@ -91,13 +96,13 @@ export default function MemberListItem(props: Props) { - - + {(props.team.membership.role === MembershipRole.OWNER || props.team.membership.role === MembershipRole.ADMIN) && ( <> @@ -106,10 +111,11 @@ export default function MemberListItem(props: Props) { onClick={() => setShowChangeMemberRoleModal(true)} color="minimal" StartIcon={PencilIcon} - className="flex-shrink-0 w-full"> + className="flex-shrink-0 w-full font-normal"> {t("edit_role")} + @@ -119,7 +125,7 @@ export default function MemberListItem(props: Props) { }} color="warn" StartIcon={UserRemoveIcon} - className="w-full"> + className="w-full font-normal"> {t("remove_member")} @@ -151,9 +157,11 @@ export default function MemberListItem(props: Props) {
- - - + {props.team.membership.role !== MembershipRole.MEMBER && ( + + + + )}
)} diff --git a/components/team/TeamListItem.tsx b/components/team/TeamListItem.tsx index ce81a722..8677e65d 100644 --- a/components/team/TeamListItem.tsx +++ b/components/team/TeamListItem.tsx @@ -17,6 +17,7 @@ import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + DropdownMenuSeparator, } from "@components/ui/Dropdown"; import TeamRole from "./TeamRole"; @@ -123,23 +124,33 @@ export default function TeamListItem(props: Props) { - )} + {isAdmin && } - + {isOwner && ( @@ -150,7 +161,7 @@ export default function TeamListItem(props: Props) { }} color="warn" StartIcon={TrashIcon} - className="w-full"> + className="w-full font-normal"> {t("disband_team")} @@ -164,6 +175,7 @@ export default function TeamListItem(props: Props) { )} + {!isOwner && ( diff --git a/components/team/TeamSettings.tsx b/components/team/TeamSettings.tsx index e5fc9eed..7b98e57d 100644 --- a/components/team/TeamSettings.tsx +++ b/components/team/TeamSettings.tsx @@ -87,7 +87,7 @@ export default function TeamSettings(props: Props) { htmlFor="team-url" Input={ diff --git a/components/team/TeamSettingsRightSidebar.tsx b/components/team/TeamSettingsRightSidebar.tsx index 68e22b1e..d1556b72 100644 --- a/components/team/TeamSettingsRightSidebar.tsx +++ b/components/team/TeamSettingsRightSidebar.tsx @@ -109,8 +109,7 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers; )} - {/* TODO: Team availability */} - {props.team?.id && ( + {props.team?.id && props.role !== MembershipRole.MEMBER && (
{"View Availability"} diff --git a/components/ui/Alert.tsx b/components/ui/Alert.tsx index b38fa48e..f717ccc0 100644 --- a/components/ui/Alert.tsx +++ b/components/ui/Alert.tsx @@ -33,7 +33,7 @@ export function Alert(props: AlertProps) {
-
+

{props.title}

{props.message}
diff --git a/ee/components/team/availability/TeamAvailabilityModal.tsx b/ee/components/team/availability/TeamAvailabilityModal.tsx index db269917..7cd287a8 100644 --- a/ee/components/team/availability/TeamAvailabilityModal.tsx +++ b/ee/components/team/availability/TeamAvailabilityModal.tsx @@ -8,7 +8,7 @@ import { trpc, inferQueryOutput } from "@lib/trpc"; import Avatar from "@components/ui/Avatar"; import { DatePicker } from "@components/ui/form/DatePicker"; -import MinutesField from "@components/ui/form/MinutesField"; +import Select from "@components/ui/form/Select"; import TeamAvailabilityTimes from "./TeamAvailabilityTimes"; @@ -22,8 +22,10 @@ interface Props { export default function TeamAvailabilityModal(props: Props) { const utils = trpc.useContext(); const [selectedDate, setSelectedDate] = useState(dayjs()); - const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess); - const [frequency, setFrequency] = useState(30); + const [selectedTimeZone, setSelectedTimeZone] = useState( + localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() + ); + const [frequency, setFrequency] = useState<15 | 30 | 60>(30); useEffect(() => { utils.invalidateQueries(["viewer.teams.getMemberAvailability"]); @@ -64,24 +66,25 @@ export default function TeamAvailabilityModal(props: Props) {
Slot Length - { - setFrequency(Number(e.target.value)); - }} + setFrequency(newFrequency?.value ?? 30)} />
@@ -105,10 +104,10 @@ export default function TeamAvailabilityScreen(props: Props) { {({ height, width }) => ( {Item} diff --git a/ee/components/team/availability/TeamAvailabilityTimes.tsx b/ee/components/team/availability/TeamAvailabilityTimes.tsx index 691afe01..3190e960 100644 --- a/ee/components/team/availability/TeamAvailabilityTimes.tsx +++ b/ee/components/team/availability/TeamAvailabilityTimes.tsx @@ -5,13 +5,13 @@ import React from "react"; import { ITimezone } from "react-timezone-select"; import getSlots from "@lib/slots"; -import { inferQueryOutput, trpc } from "@lib/trpc"; +import { trpc } from "@lib/trpc"; import Loader from "@components/Loader"; interface Props { - team: inferQueryOutput<"viewer.teams.get">; - member: inferQueryOutput<"viewer.teams.get">["members"][number]; + teamId: number; + memberId: number; selectedDate: Dayjs; selectedTimeZone: ITimezone; frequency: number; @@ -26,8 +26,8 @@ export default function TeamAvailabilityTimes(props: Props) { [ "viewer.teams.getMemberAvailability", { - teamId: props.team.id, - memberId: props.member.id, + teamId: props.teamId, + memberId: props.memberId, dateFrom: props.selectedDate.toString(), dateTo: props.selectedDate.add(1, "day").toString(), timezone: `${props.selectedTimeZone.toString()}`, diff --git a/ee/pages/settings/teams/[id]/availability.tsx b/ee/pages/settings/teams/[id]/availability.tsx index 28c12a26..2f291c99 100644 --- a/ee/pages/settings/teams/[id]/availability.tsx +++ b/ee/pages/settings/teams/[id]/availability.tsx @@ -1,5 +1,5 @@ import { useRouter } from "next/router"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import TeamAvailabilityScreen from "@ee/components/team/availability/TeamAvailabilityScreen"; @@ -7,15 +7,18 @@ import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar"; import { trpc } from "@lib/trpc"; import Loader from "@components/Loader"; -import Shell from "@components/Shell"; +import Shell, { useMeQuery } from "@components/Shell"; import { Alert } from "@components/ui/Alert"; import Avatar from "@components/ui/Avatar"; -export function TeamSettingsPage() { +export function TeamAvailabilityPage() { const router = useRouter(); const [errorMessage, setErrorMessage] = useState(""); + const me = useMeQuery(); + const isFreeUser = me.data?.plan === "FREE"; + const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], { refetchOnWindowFocus: false, onError: (e) => { @@ -23,14 +26,20 @@ export function TeamSettingsPage() { }, }); + // prevent unnecessary re-renders due to shell queries + const TeamAvailability = useMemo(() => { + return ; + }, [team]); + return ( {!!errorMessage && } {isLoading && } - {team && } + {isFreeUser ? ( + + ) : ( + TeamAvailability + )} ); } -export default TeamSettingsPage; +export default TeamAvailabilityPage; diff --git a/pages/api/availability/eventtype.ts b/pages/api/availability/eventtype.ts index 3aaa54b4..5b8f4af7 100644 --- a/pages/api/availability/eventtype.ts +++ b/pages/api/availability/eventtype.ts @@ -86,7 +86,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const isAuthorized = (function () { if (event.team) { return event.team.members - .filter((member) => member.role === MembershipRole.OWNER) + .filter((member) => member.role === MembershipRole.OWNER || member.role === MembershipRole.ADMIN) .map((member) => member.userId) .includes(session.user.id); } diff --git a/pages/settings/teams.tsx b/pages/settings/teams.tsx index ab1d0c86..d4c43140 100644 --- a/pages/settings/teams.tsx +++ b/pages/settings/teams.tsx @@ -1,4 +1,5 @@ import { PlusIcon } from "@heroicons/react/solid"; +import classNames from "classnames"; import { useSession } from "next-auth/client"; import { useState } from "react"; @@ -7,7 +8,7 @@ import { trpc } from "@lib/trpc"; import Loader from "@components/Loader"; import SettingsShell from "@components/SettingsShell"; -import Shell from "@components/Shell"; +import Shell, { useMeQuery } from "@components/Shell"; import TeamCreateModal from "@components/team/TeamCreateModal"; import TeamList from "@components/team/TeamList"; import { Alert } from "@components/ui/Alert"; @@ -19,6 +20,8 @@ export default function Teams() { const [showCreateTeamModal, setShowCreateTeamModal] = useState(false); const [errorMessage, setErrorMessage] = useState(""); + const me = useMeQuery(); + const { data } = trpc.useQuery(["viewer.teams.list"], { onError: (e) => { setErrorMessage(e.message); @@ -29,15 +32,34 @@ export default function Teams() { const teams = data?.filter((m) => m.accepted) || []; const invites = data?.filter((m) => !m.accepted) || []; + const isFreePlan = me.data?.plan === "FREE"; return ( {!!errorMessage && } - + {isFreePlan && ( + {t("plan_upgrade_teams")}} + message={ + <> + {t("to_upgrade_go_to")}{" "} + + {"https://cal.com/upgrade"} + + + } + className="my-4" + /> + )} {showCreateTeamModal && setShowCreateTeamModal(false)} />} -
- diff --git a/public/static/locales/en/common.json b/public/static/locales/en/common.json index cb4d418f..7b010501 100644 --- a/public/static/locales/en/common.json +++ b/public/static/locales/en/common.json @@ -445,6 +445,7 @@ "hidden": "Hidden", "readonly": "Readonly", "plan_upgrade": "You need to upgrade your plan to have more than one active event type.", + "plan_upgrade_teams": "You need to upgrade your plan to create a team.", "plan_upgrade_instructions": "To upgrade, go to https://cal.com/upgrade", "event_types_page_title": "Event Types", "event_types_page_subtitle": "Create events to share for people to book on your calendar.", diff --git a/server/createContext.ts b/server/createContext.ts index 1e68e9ff..316fbb88 100644 --- a/server/createContext.ts +++ b/server/createContext.ts @@ -44,6 +44,7 @@ async function getUserFromSession({ avatar: true, twoFactorEnabled: true, brandColor: true, + plan: true, credentials: { select: { id: true, diff --git a/server/routers/viewer.tsx b/server/routers/viewer.tsx index 6c28e237..4a249a3f 100644 --- a/server/routers/viewer.tsx +++ b/server/routers/viewer.tsx @@ -1,4 +1,4 @@ -import { BookingStatus, Prisma } from "@prisma/client"; +import { BookingStatus, MembershipRole, Prisma } from "@prisma/client"; import _ from "lodash"; import { z } from "zod"; @@ -57,6 +57,7 @@ const loggedInViewerRouter = createProtectedRouter() completedOnboarding, twoFactorEnabled, brandColor, + plan, } = ctx.user; const me = { id, @@ -72,6 +73,7 @@ const loggedInViewerRouter = createProtectedRouter() completedOnboarding, twoFactorEnabled, brandColor, + plan, }; return me; }, @@ -231,7 +233,7 @@ const loggedInViewerRouter = createProtectedRouter() }, metadata: { membershipCount: membership.team.members.length, - readOnly: membership.role !== "OWNER", + readOnly: membership.role === MembershipRole.MEMBER, }, eventTypes: membership.team.eventTypes, })) diff --git a/server/routers/viewer/teams.tsx b/server/routers/viewer/teams.tsx index 9db707dd..20dfe05e 100644 --- a/server/routers/viewer/teams.tsx +++ b/server/routers/viewer/teams.tsx @@ -58,6 +58,10 @@ export const viewerTeamsRouter = createProtectedRouter() name: z.string(), }), async resolve({ ctx, input }) { + if (ctx.user.plan === "FREE") { + throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a pro user." }); + } + const slug = slugify(input.name); const nameCollisions = await ctx.prisma.team.count({