diff --git a/components/eventtype/EventTypeList.tsx b/components/eventtype/EventTypeList.tsx deleted file mode 100644 index de2cf33af7..0000000000 --- a/components/eventtype/EventTypeList.tsx +++ /dev/null @@ -1,185 +0,0 @@ -// TODO: replace headlessui with radix-ui -import { Menu, Transition } from "@headlessui/react"; -import { DotsHorizontalIcon, ExternalLinkIcon, LinkIcon } from "@heroicons/react/solid"; -import Link from "next/link"; -import React, { Fragment } from "react"; - -import classNames from "@lib/classNames"; -import { useLocale } from "@lib/hooks/useLocale"; -import showToast from "@lib/notification"; - -import { Tooltip } from "@components/Tooltip"; -import EventTypeDescription from "@components/eventtype/EventTypeDescription"; -import AvatarGroup from "@components/ui/AvatarGroup"; - -interface Props { - profile: { slug: string }; - readOnly: boolean; - types: { - $disabled: boolean; - hidden: boolean; - id: number; - slug: string; - title: string; - users: { - name: string; - avatar: string; - }[]; - }; -} - -const EventTypeList = ({ readOnly, types, profile }: Props): JSX.Element => { - const { t } = useLocale(); - return ( -
- -
- ); -}; - -export default EventTypeList; diff --git a/components/eventtype/EventTypeListHeading.tsx b/components/eventtype/EventTypeListHeading.tsx deleted file mode 100644 index cbf4b884e1..0000000000 --- a/components/eventtype/EventTypeListHeading.tsx +++ /dev/null @@ -1,58 +0,0 @@ -// TODO: replace headlessui with radix-ui -import { UsersIcon } from "@heroicons/react/solid"; -import Link from "next/link"; -import React from "react"; - -import Avatar from "@components/ui/Avatar"; -import Badge from "@components/ui/Badge"; - -interface Props { - profile: { - slug?: string | null; - name?: string | null; - image?: string | null; - }; - membershipCount: number; -} - -const EventTypeListHeading = ({ profile, membershipCount }: Props): JSX.Element => ( -
- - - - - -
- - {profile?.name || ""} - - {membershipCount && ( - - - - - - {membershipCount} - - - - - )} - {profile?.slug && ( - - {`${process.env.NEXT_PUBLIC_APP_URL?.replace( - "https://", - "" - )}/${profile.slug}`} - - )} -
-
-); - -export default EventTypeListHeading; diff --git a/components/eventtype/CustomInputTypeForm.tsx b/components/pages/eventtypes/CustomInputTypeForm.tsx similarity index 100% rename from components/eventtype/CustomInputTypeForm.tsx rename to components/pages/eventtypes/CustomInputTypeForm.tsx diff --git a/lib/mutations/event-types/create-event-type.ts b/lib/mutations/event-types/create-event-type.ts index 54a1e7380b..9849da3eca 100644 --- a/lib/mutations/event-types/create-event-type.ts +++ b/lib/mutations/event-types/create-event-type.ts @@ -1,10 +1,11 @@ -import { EventType } from "@prisma/client"; - import * as fetch from "@lib/core/http/fetch-wrapper"; -import { CreateEventType } from "@lib/types/event-type"; +import { CreateEventType, CreateEventTypeResponse } from "@lib/types/event-type"; const createEventType = async (data: CreateEventType) => { - const response = await fetch.post("/api/availability/eventtype", data); + const response = await fetch.post( + "/api/availability/eventtype", + data + ); return response; }; diff --git a/lib/types/event-type.ts b/lib/types/event-type.ts index a535e0c096..337d83b623 100644 --- a/lib/types/event-type.ts +++ b/lib/types/event-type.ts @@ -1,4 +1,4 @@ -import { SchedulingType } from "@prisma/client"; +import { SchedulingType, EventType } from "@prisma/client"; export type OpeningHours = { days: number[]; @@ -49,9 +49,14 @@ export type CreateEventType = { slug: string; description: string; length: number; + teamId?: number; schedulingType?: SchedulingType; }; +export type CreateEventTypeResponse = { + eventType: EventType; +}; + export type EventTypeInput = AdvancedOptions & { id: number; title: string; diff --git a/package.json b/package.json index 57a6d53d85..4f1ae066f6 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test": "jest", "test-playwright": "jest --config jest.playwright.config.js", "test-playwright-lcov": "cross-env PLAYWRIGHT_HEADLESS=1 PLAYWRIGHT_COVERAGE=1 yarn test-playwright && nyc report --reporter=lcov", + "type-check": "tsc --pretty --noEmit", "build": "next build", "start": "next start", "ts-node": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\"", diff --git a/pages/event-types/[type].tsx b/pages/event-types/[type].tsx index 759044cb0b..ae890c869d 100644 --- a/pages/event-types/[type].tsx +++ b/pages/event-types/[type].tsx @@ -50,7 +50,7 @@ import { inferSSRProps } from "@lib/types/inferSSRProps"; import { Dialog, DialogContent, DialogTrigger } from "@components/Dialog"; import Shell from "@components/Shell"; import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; -import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm"; +import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm"; import Button from "@components/ui/Button"; import { Scheduler } from "@components/ui/Scheduler"; import Switch from "@components/ui/Switch"; diff --git a/pages/event-types/index.tsx b/pages/event-types/index.tsx index 32f5367c35..aa082a19d1 100644 --- a/pages/event-types/index.tsx +++ b/pages/event-types/index.tsx @@ -1,31 +1,33 @@ // TODO: replace headlessui with radix-ui +import { Menu, Transition } from "@headlessui/react"; +import { UsersIcon } from "@heroicons/react/solid"; import { ChevronDownIcon, PlusIcon } from "@heroicons/react/solid"; -import { Prisma, SchedulingType } from "@prisma/client"; -import { GetServerSidePropsContext } from "next"; -import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { DotsHorizontalIcon, ExternalLinkIcon, LinkIcon } from "@heroicons/react/solid"; +import { SchedulingType } from "@prisma/client"; import Head from "next/head"; +import Link from "next/link"; import { useRouter } from "next/router"; import React, { Fragment, useRef } from "react"; import { useMutation } from "react-query"; -import { asStringOrNull } from "@lib/asStringOrNull"; -import { getSession } from "@lib/auth"; +import { QueryCell } from "@lib/QueryCell"; +import classNames from "@lib/classNames"; import { HttpError } from "@lib/core/http/error"; -import { getOrSetUserLocaleFromHeaders } from "@lib/core/i18n/i18n.utils"; -import { ONBOARDING_NEXT_REDIRECT, shouldShowOnboarding } from "@lib/getting-started"; import { useLocale } from "@lib/hooks/useLocale"; import { useToggleQuery } from "@lib/hooks/useToggleQuery"; import createEventType from "@lib/mutations/event-types/create-event-type"; import showToast from "@lib/notification"; -import prisma from "@lib/prisma"; -import { inferSSRProps } from "@lib/types/inferSSRProps"; +import { inferQueryOutput, trpc } from "@lib/trpc"; +import { CreateEventType } from "@lib/types/event-type"; import { Dialog, DialogClose, DialogContent } from "@components/Dialog"; import Shell from "@components/Shell"; -import EventTypeList from "@components/eventtype/EventTypeList"; -import EventTypeListHeading from "@components/eventtype/EventTypeListHeading"; +import { Tooltip } from "@components/Tooltip"; +import EventTypeDescription from "@components/eventtype/EventTypeDescription"; import { Alert } from "@components/ui/Alert"; import Avatar from "@components/ui/Avatar"; +import AvatarGroup from "@components/ui/AvatarGroup"; +import Badge from "@components/ui/Badge"; import { Button } from "@components/ui/Button"; import Dropdown, { DropdownMenuContent, @@ -37,22 +39,236 @@ import Dropdown, { import * as RadioArea from "@components/ui/form/radio-area"; import UserCalendarIllustration from "@components/ui/svg/UserCalendarIllustration"; -type PageProps = inferSSRProps; -type Profile = PageProps["profiles"][number]; +type Profiles = inferQueryOutput<"viewer.eventTypes">["profiles"]; -const EventTypesPage = (props: PageProps) => { +interface CreateEventTypeProps { + canAddEvents: boolean; + profiles: Profiles; +} + +const CreateFirstEventTypeView = ({ canAddEvents, profiles }: CreateEventTypeProps) => { const { t } = useLocale(); - const CreateFirstEventTypeView = () => ( + return (

{t("new_event_type_heading")}

{t("new_event_type_description")}

- +
); +}; + +type EventTypeGroup = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"][number]; +type EventType = EventTypeGroup["eventTypes"][number]; +interface EventTypeListProps { + profile: { slug: string | null }; + readOnly: boolean; + types: EventType[]; +} +const EventTypeList = ({ readOnly, types, profile }: EventTypeListProps): JSX.Element => { + const { t } = useLocale(); + return ( +
+
    + {types.map((type) => ( +
  • +
    +
    + + +
    + {type.title} + {type.hidden && ( + + {t("hidden")} + + )} + {readOnly && ( + + {t("readonly")} + + )} +
    + +
    + + +
    +
    + {type.users?.length > 1 && ( + ({ + alt: organizer.name || "", + image: organizer.avatar || "", + }))} + /> + )} + + + + + + + + + +
    +
    +
    +
    + + {({ open }) => ( + <> +
    + + {t("open_options")} + +
    + + + +
    + + {({ active }) => ( + + + )} + + + {({ active }) => ( + + )} + +
    +
    +
    + + )} +
    +
    +
    +
  • + ))} +
+
+ ); +}; + +interface EventTypeListHeadingProps { + profile: Profile; + membershipCount: number; +} +const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeadingProps): JSX.Element => ( +
+ + + + + +
+ + {profile?.name || ""} + + {membershipCount && ( + + + + + + {membershipCount} + + + + + )} + {profile?.slug && ( + + {`${process.env.NEXT_PUBLIC_APP_URL?.replace( + "https://", + "" + )}/${profile.slug}`} + + )} +
+
+); + +const EventTypesPage = () => { + const { t } = useLocale(); + const query = trpc.useQuery(["viewer.eventTypes"]); return (
@@ -64,50 +280,60 @@ const EventTypesPage = (props: PageProps) => { heading={t("event_types_page_title")} subtitle={t("event_types_page_subtitle")} CTA={ - props.eventTypes.length !== 0 && ( - + query.data && + query.data.eventTypeGroups.length !== 0 && ( + ) }> - {props.user.plan === "FREE" && !props.canAddEvents && ( - {t("plan_upgrade")}} - message={ - <> - {t("to_upgrade_go_to")}{" "} - - {"https://cal.com/upgrade"} - - - } - className="my-4" - /> - )} - {props.eventTypes && - props.eventTypes.map((input) => ( - - {/* hide list heading when there is only one (current user) */} - {(props.eventTypes.length !== 1 || input.teamId) && ( - ( + <> + {data.user.plan === "FREE" && !data.canAddEvents && ( + {t("plan_upgrade")}} + message={ + <> + {t("to_upgrade_go_to")}{" "} + + {"https://cal.com/upgrade"} + + + } + className="my-4" /> )} - - - ))} + {data.eventTypeGroups && + data.eventTypeGroups.map((input) => ( + + {/* hide list heading when there is only one (current user) */} + {(data.eventTypeGroups.length !== 1 || input.teamId) && ( + + )} + + + ))} - {props.eventTypes.length === 0 && } + {data.eventTypeGroups.length === 0 && ( + + )} + + )} + />
); }; -const CreateNewEventDialog = ({ profiles, canAddEvents }: { profiles: Profile[]; canAddEvents: boolean }) => { +const CreateNewEventButton = ({ profiles, canAddEvents }: CreateEventTypeProps) => { const router = useRouter(); const teamId: number | null = Number(router.query.teamId) || null; const modalOpen = useToggleQuery("new"); @@ -173,12 +399,7 @@ const CreateNewEventDialog = ({ profiles, canAddEvents }: { profiles: Profile[]; }, }) }> - + {profile.name ? profile.name : profile.slug} ))} @@ -203,7 +424,7 @@ const CreateNewEventDialog = ({ profiles, canAddEvents }: { profiles: Profile[]; { value: string } >; - const payload = { + const payload: CreateEventType = { title: target.title.value, slug: target.slug.value, description: target.description.value, @@ -211,8 +432,8 @@ const CreateNewEventDialog = ({ profiles, canAddEvents }: { profiles: Profile[]; }; if (router.query.teamId) { - payload.teamId = parseInt(asStringOrNull(router.query.teamId), 10); - payload.schedulingType = target.schedulingType.value; + payload.teamId = parseInt(`${router.query.teamId}`, 10); + payload.schedulingType = target.schedulingType.value as SchedulingType; } createMutation.mutate(payload); @@ -325,188 +546,4 @@ const CreateNewEventDialog = ({ profiles, canAddEvents }: { profiles: Profile[]; ); }; -export async function getServerSideProps(context: GetServerSidePropsContext) { - const session = await getSession(context); - const locale = await getOrSetUserLocaleFromHeaders(context.req); - - if (!session?.user?.id) { - return { redirect: { permanent: false, destination: "/auth/login" } }; - } - - /** - * This makes the select reusable and type safe. - * @url https://www.prisma.io/docs/concepts/components/prisma-client/advanced-type-safety/prisma-validator#using-the-prismavalidator - * */ - const eventTypeSelect = Prisma.validator()({ - id: true, - title: true, - description: true, - length: true, - schedulingType: true, - slug: true, - hidden: true, - price: true, - currency: true, - users: { - select: { - id: true, - avatar: true, - name: true, - }, - }, - }); - - const user = await prisma.user.findUnique({ - where: { - id: session.user.id, - }, - select: { - id: true, - username: true, - name: true, - startTime: true, - endTime: true, - bufferTime: true, - avatar: true, - completedOnboarding: true, - createdDate: true, - plan: true, - teams: { - where: { - accepted: true, - }, - select: { - role: true, - team: { - select: { - id: true, - name: true, - slug: true, - logo: true, - members: { - select: { - userId: true, - }, - }, - eventTypes: { - select: eventTypeSelect, - }, - }, - }, - }, - }, - eventTypes: { - where: { - team: null, - }, - select: eventTypeSelect, - }, - }, - }); - - if (!user) { - // this shouldn't happen - return { - redirect: { - permanent: false, - destination: "/auth/login", - }, - }; - } - - if ( - shouldShowOnboarding({ completedOnboarding: user.completedOnboarding, createdDate: user.createdDate }) - ) { - return ONBOARDING_NEXT_REDIRECT; - } - - // backwards compatibility, TMP: - const typesRaw = await prisma.eventType.findMany({ - where: { - userId: session.user.id, - }, - select: eventTypeSelect, - }); - - type EventTypeGroup = { - teamId?: number | null; - profile?: { - slug: typeof user["username"]; - name: typeof user["name"]; - image: typeof user["avatar"]; - }; - metadata: { - membershipCount: number; - readOnly: boolean; - }; - eventTypes: (typeof user.eventTypes[number] & { $disabled?: boolean })[]; - }; - - let eventTypeGroups: EventTypeGroup[] = []; - const eventTypesHashMap = user.eventTypes.concat(typesRaw).reduce((hashMap, newItem) => { - const oldItem = hashMap[newItem.id] || {}; - hashMap[newItem.id] = { ...oldItem, ...newItem }; - return hashMap; - }, {} as Record); - const mergedEventTypes = Object.values(eventTypesHashMap).map((et, index) => ({ - ...et, - $disabled: user.plan === "FREE" && index > 0, - })); - - eventTypeGroups.push({ - teamId: null, - profile: { - slug: user.username, - name: user.name, - image: user.avatar, - }, - eventTypes: mergedEventTypes, - metadata: { - membershipCount: 1, - readOnly: false, - }, - }); - - eventTypeGroups = ([] as EventTypeGroup[]).concat( - eventTypeGroups, - user.teams.map((membership) => ({ - teamId: membership.team.id, - profile: { - name: membership.team.name, - image: membership.team.logo || "", - slug: "team/" + membership.team.slug, - }, - metadata: { - membershipCount: membership.team.members.length, - readOnly: membership.role !== "OWNER", - }, - eventTypes: membership.team.eventTypes, - })) - ); - - const userObj = Object.assign({}, user, { - createdDate: user.createdDate.toString(), - }); - - const canAddEvents = user.plan !== "FREE" || eventTypeGroups[0].eventTypes.length < 1; - - return { - props: { - session, - localeProp: locale, - canAddEvents, - user: userObj, - // don't display event teams without event types, - eventTypes: eventTypeGroups.filter((groupBy) => !!groupBy.eventTypes?.length), - // so we can show a dropdown when the user has teams - profiles: eventTypeGroups.map((group) => ({ - teamId: group.teamId, - ...group.profile, - ...group.metadata, - })), - ...(await serverSideTranslations(locale, ["common"])), - }, - }; -} - export default EventTypesPage; diff --git a/server/routers/viewer.tsx b/server/routers/viewer.tsx index 3209286de8..e58451f3df 100644 --- a/server/routers/viewer.tsx +++ b/server/routers/viewer.tsx @@ -71,6 +71,156 @@ const loggedInViewerRouter = createProtectedRouter() return me; }, }) + .query("eventTypes", { + async resolve({ ctx }) { + const { prisma } = ctx; + const eventTypeSelect = Prisma.validator()({ + id: true, + title: true, + description: true, + length: true, + schedulingType: true, + slug: true, + hidden: true, + price: true, + currency: true, + users: { + select: { + id: true, + avatar: true, + name: true, + }, + }, + }); + + const user = await prisma.user.findUnique({ + where: { + id: ctx.user.id, + }, + select: { + id: true, + username: true, + name: true, + startTime: true, + endTime: true, + bufferTime: true, + avatar: true, + plan: true, + teams: { + where: { + accepted: true, + }, + select: { + role: true, + team: { + select: { + id: true, + name: true, + slug: true, + logo: true, + members: { + select: { + userId: true, + }, + }, + eventTypes: { + select: eventTypeSelect, + }, + }, + }, + }, + }, + eventTypes: { + where: { + team: null, + }, + select: eventTypeSelect, + }, + }, + }); + + if (!user) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + + // backwards compatibility, TMP: + const typesRaw = await prisma.eventType.findMany({ + where: { + userId: ctx.user.id, + }, + select: eventTypeSelect, + }); + + type EventTypeGroup = { + teamId?: number | null; + profile: { + slug: typeof user["username"]; + name: typeof user["name"]; + image: typeof user["avatar"]; + }; + metadata: { + membershipCount: number; + readOnly: boolean; + }; + eventTypes: (typeof user.eventTypes[number] & { $disabled?: boolean })[]; + }; + + let eventTypeGroups: EventTypeGroup[] = []; + const eventTypesHashMap = user.eventTypes.concat(typesRaw).reduce((hashMap, newItem) => { + const oldItem = hashMap[newItem.id] || {}; + hashMap[newItem.id] = { ...oldItem, ...newItem }; + return hashMap; + }, {} as Record); + const mergedEventTypes = Object.values(eventTypesHashMap).map((et, index) => ({ + ...et, + $disabled: user.plan === "FREE" && index > 0, + })); + + eventTypeGroups.push({ + teamId: null, + profile: { + slug: user.username, + name: user.name, + image: user.avatar, + }, + eventTypes: mergedEventTypes, + metadata: { + membershipCount: 1, + readOnly: false, + }, + }); + + eventTypeGroups = ([] as EventTypeGroup[]).concat( + eventTypeGroups, + user.teams.map((membership) => ({ + teamId: membership.team.id, + profile: { + name: membership.team.name, + image: membership.team.logo || "", + slug: "team/" + membership.team.slug, + }, + metadata: { + membershipCount: membership.team.members.length, + readOnly: membership.role !== "OWNER", + }, + eventTypes: membership.team.eventTypes, + })) + ); + + const canAddEvents = user.plan !== "FREE" || eventTypeGroups[0].eventTypes.length < 1; + + return { + canAddEvents, + user, + // don't display event teams without event types, + eventTypeGroups: eventTypeGroups.filter((groupBy) => !!groupBy.eventTypes?.length), + // so we can show a dropdown when the user has teams + profiles: eventTypeGroups.map((group) => ({ + teamId: group.teamId, + ...group.profile, + ...group.metadata, + })), + }; + }, + }) .query("bookings", { input: z.object({ status: z.enum(["upcoming", "past", "cancelled"]),