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
This commit is contained in:
Jamie Pine 2021-12-16 16:16:59 -08:00 committed by GitHub
parent 4ce879e5dc
commit c21f0c2d49
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 141 additions and 73 deletions

View file

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

View file

@ -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) {
<DropdownMenuItem>
<Link href={"/" + props.member.username}>
<a target="_blank">
<Button color="minimal" StartIcon={ExternalLinkIcon} className="w-full">
<Button color="minimal" StartIcon={ExternalLinkIcon} className="w-full font-normal">
{t("view_public_page")}
</Button>
</a>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{(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")}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
@ -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")}
</Button>
</DialogTrigger>
@ -151,9 +157,11 @@ export default function MemberListItem(props: Props) {
<TeamAvailabilityModal team={props.team} member={props.member} />
<div className="p-5 space-x-2 border-t">
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
<Link href={`/settings/teams/${props.team.id}/availability`}>
<Button color="secondary">{t("Open Team Availability")}</Button>
</Link>
{props.team.membership.role !== MembershipRole.MEMBER && (
<Link href={`/settings/teams/${props.team.id}/availability`}>
<Button color="secondary">{t("Open Team Availability")}</Button>
</Link>
)}
</div>
</ModalContainer>
)}

View file

@ -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) {
<DropdownMenuItem>
<Link href={"/settings/teams/" + team.id}>
<a>
<Button type="button" color="minimal" className="w-full" StartIcon={PencilIcon}>
<Button
type="button"
color="minimal"
className="w-full font-normal"
StartIcon={PencilIcon}>
{t("edit_team")}
</Button>
</a>
</Link>
</DropdownMenuItem>
)}
{isAdmin && <DropdownMenuSeparator className="h-px bg-gray-200" />}
<DropdownMenuItem>
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${team.slug}`} passHref={true}>
<a target="_blank">
<Button type="button" color="minimal" className="w-full" StartIcon={ExternalLinkIcon}>
<Button
type="button"
color="minimal"
className="w-full font-normal"
StartIcon={ExternalLinkIcon}>
{" "}
{t("preview_team")}
</Button>
</a>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{isOwner && (
<DropdownMenuItem>
<Dialog>
@ -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")}
</Button>
</DialogTrigger>
@ -164,6 +175,7 @@ export default function TeamListItem(props: Props) {
</Dialog>
</DropdownMenuItem>
)}
{!isOwner && (
<DropdownMenuItem>
<Dialog>

View file

@ -87,7 +87,7 @@ export default function TeamSettings(props: Props) {
htmlFor="team-url"
Input={
<TextField
name="team-url"
name="" // typescript requires name but we don't want component to render name label
id="team-url"
addOnLeading={
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">

View file

@ -109,8 +109,7 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
</Dialog>
)}
</div>
{/* TODO: Team availability */}
{props.team?.id && (
{props.team?.id && props.role !== MembershipRole.MEMBER && (
<Link href={`/settings/teams/${props.team.id}/availability`}>
<div className="mt-5 space-y-1">
<LinkIconButton Icon={ClockIcon}>{"View Availability"}</LinkIconButton>

View file

@ -33,7 +33,7 @@ export function Alert(props: AlertProps) {
<CheckCircleIcon className={classNames("h-5 w-5 text-gray-400")} aria-hidden="true" />
)}
</div>
<div className="ml-3 flex-grow">
<div className="flex-grow ml-3">
<h3 className="text-sm font-medium">{props.title}</h3>
<div className="text-sm">{props.message}</div>
</div>

View file

@ -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<ITimezone>(dayjs.tz.guess);
const [frequency, setFrequency] = useState<number>(30);
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(
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) {
</div>
<div>
<span className="font-bold text-gray-600">Slot Length</span>
<MinutesField
id="length"
label=""
required
min="10"
placeholder="15"
defaultValue={frequency}
onChange={(e) => {
setFrequency(Number(e.target.value));
}}
<Select
options={[
{ value: 15, label: "15 minutes" },
{ value: 30, label: "30 minutes" },
{ value: 60, label: "60 minutes" },
]}
isSearchable={false}
classNamePrefix="react-select"
className="flex-1 block w-full min-w-0 border border-gray-300 rounded-sm react-select-container focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
value={{ value: frequency, label: `${frequency} minutes` }}
onChange={(newFrequency) => setFrequency(newFrequency?.value ?? 30)}
/>
</div>
</div>
{props.team && props.member && (
<TeamAvailabilityTimes
className="overflow-scroll"
team={props.team}
member={props.member}
teamId={props.team.id}
memberId={props.member.id}
frequency={frequency}
selectedDate={selectedDate}
selectedTimeZone={selectedTimeZone}

View file

@ -9,22 +9,21 @@ 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";
interface Props {
team?: inferQueryOutput<"viewer.teams.get">;
members?: inferQueryOutput<"viewer.teams.get">["members"];
}
// type Member = inferQueryOutput<"viewer.teams.get">["members"][number];
export default function TeamAvailabilityScreen(props: Props) {
const utils = trpc.useContext();
const [selectedDate, setSelectedDate] = useState(dayjs());
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(dayjs.tz.guess());
const [frequency, setFrequency] = useState<number>(30);
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
);
const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
useEffect(() => {
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
@ -32,14 +31,14 @@ export default function TeamAvailabilityScreen(props: Props) {
}, [selectedTimeZone, selectedDate]);
const Item = ({ index, style }: { index: number; style: CSSProperties }) => {
const member = props.members?.[index];
const member = props.team?.members?.[index];
if (!member) return <></>;
return (
<div key={member.id} style={style} className="flex pl-4 border-r border-gray-200 ">
<TeamAvailabilityTimes
team={props.team as inferQueryOutput<"viewer.teams.get">}
member={member}
teamId={props.team?.id as number}
memberId={member.id}
frequency={frequency}
selectedDate={selectedDate}
selectedTimeZone={selectedTimeZone}
@ -86,17 +85,17 @@ export default function TeamAvailabilityScreen(props: Props) {
</div>
<div>
<span className="font-bold text-gray-600">Slot Length</span>
<MinutesField
id="length"
label=""
required
min="10"
className="p-2.5"
placeholder="15"
defaultValue={frequency}
onChange={(e) => {
setFrequency(Number(e.target.value));
}}
<Select
options={[
{ value: 15, label: "15 minutes" },
{ value: 30, label: "30 minutes" },
{ value: 60, label: "60 minutes" },
]}
isSearchable={false}
classNamePrefix="react-select"
className="flex-1 block w-full min-w-0 border border-gray-300 rounded-sm react-select-container focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
value={{ value: frequency, label: `${frequency} minutes` }}
onChange={(newFrequency) => setFrequency(newFrequency?.value ?? 30)}
/>
</div>
</div>
@ -105,10 +104,10 @@ export default function TeamAvailabilityScreen(props: Props) {
{({ height, width }) => (
<List
itemSize={240}
itemCount={props.members?.length || 0}
itemCount={props.team?.members?.length ?? 0}
className="List"
height={height}
direction="horizontal"
layout="horizontal"
width={width}>
{Item}
</List>

View file

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

View file

@ -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 <TeamAvailabilityScreen team={team} />;
}, [team]);
return (
<Shell
showBackButton={!errorMessage}
heading={team?.name}
heading={!isFreeUser && team?.name}
flexChildrenContainer
subtitle={team && "Your team's availability at a glance"}
subtitle={team && !isFreeUser && "Your team's availability at a glance"}
HeadingLeftIcon={
team && (
team &&
!isFreeUser && (
<Avatar
size={12}
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
@ -41,9 +50,17 @@ export function TeamSettingsPage() {
}>
{!!errorMessage && <Alert className="-mt-24 border" severity="error" title={errorMessage} />}
{isLoading && <Loader />}
{team && <TeamAvailabilityScreen team={team} members={team.members} />}
{isFreeUser ? (
<Alert
className="-mt-24 border"
severity="warning"
title="This is a pro feature. Upgrade to pro to see your team's availability."
/>
) : (
TeamAvailability
)}
</Shell>
);
}
export default TeamSettingsPage;
export default TeamAvailabilityPage;

View file

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

View file

@ -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 (
<Shell heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
<SettingsShell>
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
{isFreePlan && (
<Alert
severity="warning"
title={<>{t("plan_upgrade_teams")}</>}
message={
<>
{t("to_upgrade_go_to")}{" "}
<a href={"https://cal.com/upgrade"} className="underline">
{"https://cal.com/upgrade"}
</a>
</>
}
className="my-4"
/>
)}
{showCreateTeamModal && <TeamCreateModal onClose={() => setShowCreateTeamModal(false)} />}
<div className="flex justify-end my-4">
<Button type="button" className="btn btn-white" onClick={() => setShowCreateTeamModal(true)}>
<div className={classNames("flex justify-end my-4", isFreePlan && "opacity-50")}>
<Button
disabled={isFreePlan}
type="button"
className="btn btn-white"
onClick={() => setShowCreateTeamModal(true)}>
<PlusIcon className="group-hover:text-black text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
{t("new_team")}
</Button>

View file

@ -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 <a href=\"https://cal.com/upgrade\" className=\"underline\">https://cal.com/upgrade</a>",
"event_types_page_title": "Event Types",
"event_types_page_subtitle": "Create events to share for people to book on your calendar.",

View file

@ -44,6 +44,7 @@ async function getUserFromSession({
avatar: true,
twoFactorEnabled: true,
brandColor: true,
plan: true,
credentials: {
select: {
id: true,

View file

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

View file

@ -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({