From 55671db5fda73a4318637459b4132e3ce05fb88b Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Tue, 29 Oct 2024 17:47:43 +0000 Subject: [PATCH] fix: virtualised member list for reassign (#17386) * wip virtual list for reassignemnt * order in alpha and prio order * loading state * add flex shrink so status symbol doesnt move * add gap to each item to make hovering not feel squished * a11y indexing tab group * useInViewObserver hook and move to lib * use correct intersection target * fix type errors * remove type --- apps/web/components/dialog/ReassignDialog.tsx | 153 +++++++++++------ .../bookings/views/bookings-listing-view.tsx | 2 +- .../views/event-types-listing-view.tsx | 2 +- .../pages/reporting/[...appPages].tsx | 5 +- packages/features/form/components/Select.tsx | 1 + .../lib/hooks/useInViewObserver.ts | 0 .../server/routers/viewer/teams/_router.tsx | 18 +- .../getRoundRobinHostsToReasign.handler.ts | 156 ++++++++++++------ .../getRoundRobinHostsToReasign.schema.ts | 12 +- 9 files changed, 226 insertions(+), 123 deletions(-) rename {apps/web => packages}/lib/hooks/useInViewObserver.ts (100%) diff --git a/apps/web/components/dialog/ReassignDialog.tsx b/apps/web/components/dialog/ReassignDialog.tsx index eba09c7af1..20d2766e2b 100644 --- a/apps/web/components/dialog/ReassignDialog.tsx +++ b/apps/web/components/dialog/ReassignDialog.tsx @@ -3,11 +3,12 @@ import { zodResolver } from "@hookform/resolvers/zod"; import type { Dispatch, SetStateAction } from "react"; import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; -import { components } from "react-select"; import { z } from "zod"; import { classNames } from "@calcom/lib"; import { ErrorCode } from "@calcom/lib/errorCodes"; +import { useDebounce } from "@calcom/lib/hooks/useDebounce"; +import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { @@ -20,7 +21,8 @@ import { DialogFooter, ConfirmationDialogContent, showToast, - Select, + Input, + Icon, RadioGroup as RadioArea, TextAreaField, } from "@calcom/ui"; @@ -49,6 +51,12 @@ const formSchema = z.object({ }), }); +interface TeamMemberOption { + label: string; + value: number; + status: string; +} + export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingId }: ReassignDialog) => { const { t } = useLocale(); const utils = trpc.useUtils(); @@ -56,28 +64,39 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI duration: 150, easing: "ease-in-out", }); - // Were using legacy list members here because we don't currently have an easy way to paginate a select via infinite scroll - const teamMembers = trpc.viewer.teams.getRoundRobinHostsToReassign.useQuery({ - bookingId, - exclude: "fixedHosts", - }); + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearch = useDebounce(searchTerm, 500); + + const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = + trpc.viewer.teams.getRoundRobinHostsToReassign.useInfiniteQuery( + { + bookingId, + exclude: "fixedHosts", + limit: 10, + searchTerm: debouncedSearch, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + } + ); + + const allRows = useMemo(() => { + return data?.pages.flatMap((page) => page.items) ?? []; + }, [data]); const teamMemberOptions = useMemo(() => { - if (teamMembers.isLoading) - return [ - { - label: "Loading...", - value: 0, - status: "unavailable", - }, - ]; - - return teamMembers.data?.map((member) => ({ - label: member.name, + return allRows.map((member) => ({ + label: member.name || member.email, value: member.id, status: member.status, - })); - }, [teamMembers]); + })) as TeamMemberOption[]; + }, [allRows]); + + const { ref: observerRef } = useInViewObserver(() => { + if (hasNextPage && !isFetching) { + fetchNextPage(); + } + }, document.querySelector('[role="dialog"]')); const form = useForm({ resolver: zodResolver(formSchema), @@ -138,6 +157,7 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI }; const watchedReassignType = form.watch("reassignType"); + const watchedTeamMemberId = form.watch("teamMemberId"); return ( <> @@ -150,24 +170,20 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI title={t("reassign_round_robin_host")} description={t("reassign_to_another_rr_host")} enableOverflow> - {/* TODO add team member reassignment*/}
{ form.setValue("reassignType", val as "team_member" | "round_robin"); }} - className={classNames("mt-1 flex flex-col gap-4")}> + className="mt-1 flex flex-col gap-4"> + className="w-full text-sm" + classNames={{ container: "w-full" }}> {t("round_robin")}

{t("round_robin_reassign_description")}

- + {t("team_member_round_robin_reassign")}

{t("team_member_round_robin_reassign_description")}

@@ -176,31 +192,62 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI {watchedReassignType === "team_member" && (
- setSearchTerm(e.target.value)} + /> +
+ {teamMemberOptions.map((member) => ( +
)} diff --git a/apps/web/modules/bookings/views/bookings-listing-view.tsx b/apps/web/modules/bookings/views/bookings-listing-view.tsx index 6dd5a6439b..20f8096dd6 100644 --- a/apps/web/modules/bookings/views/bookings-listing-view.tsx +++ b/apps/web/modules/bookings/views/bookings-listing-view.tsx @@ -11,6 +11,7 @@ import { FiltersContainer } from "@calcom/features/bookings/components/FiltersCo import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery"; import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery"; import Shell from "@calcom/features/shell/Shell"; +import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import type { RouterOutputs } from "@calcom/trpc/react"; @@ -18,7 +19,6 @@ import { trpc } from "@calcom/trpc/react"; import type { HorizontalTabItemProps, VerticalTabItemProps } from "@calcom/ui"; import { Alert, Button, EmptyScreen, HorizontalTabs } from "@calcom/ui"; -import { useInViewObserver } from "@lib/hooks/useInViewObserver"; import useMeQuery from "@lib/hooks/useMeQuery"; import BookingListItem from "@components/booking/BookingListItem"; diff --git a/apps/web/modules/event-types/views/event-types-listing-view.tsx b/apps/web/modules/event-types/views/event-types-listing-view.tsx index 0e93916164..893ecca8f8 100644 --- a/apps/web/modules/event-types/views/event-types-listing-view.tsx +++ b/apps/web/modules/event-types/views/event-types-listing-view.tsx @@ -21,6 +21,7 @@ import { parseEventTypeColor } from "@calcom/lib"; import { APP_NAME } from "@calcom/lib/constants"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useDebounce } from "@calcom/lib/hooks/useDebounce"; +import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import { useGetTheme } from "@calcom/lib/hooks/useTheme"; @@ -60,7 +61,6 @@ import { } from "@calcom/ui"; import type { AppProps } from "@lib/app-providers"; -import { useInViewObserver } from "@lib/hooks/useInViewObserver"; import useMeQuery from "@lib/hooks/useMeQuery"; type GetUserEventGroupsResponse = RouterOutputs["viewer"]["eventTypes"]["getUserEventGroups"]; diff --git a/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx b/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx index 715cd169c5..e9839be567 100644 --- a/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx @@ -12,13 +12,12 @@ import { Builder, Query, Utils as QbUtils } from "react-awesome-query-builder"; import Shell from "@calcom/features/shell/Shell"; import { classNames } from "@calcom/lib"; +import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import type { inferSSRProps } from "@calcom/types/inferSSRProps"; import { Button } from "@calcom/ui"; -import { useInViewObserver } from "@lib/hooks/useInViewObserver"; - import SingleForm, { getServerSidePropsForSingleFormView as getServerSideProps, } from "../../components/SingleForm"; @@ -67,7 +66,7 @@ const Result = ({ formId, jsonLogicQuery }: { formId: string; jsonLogicQuery: Js
{!isPending && (
- {`${numberOfRows} ` + (numberOfRows === 1 ? t("row") : t("rows"))} + {`${numberOfRows} ${numberOfRows === 1 ? t("row") : t("rows")}`}
)} ); } + export default Select; diff --git a/apps/web/lib/hooks/useInViewObserver.ts b/packages/lib/hooks/useInViewObserver.ts similarity index 100% rename from apps/web/lib/hooks/useInViewObserver.ts rename to packages/lib/hooks/useInViewObserver.ts diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index a7a88d06db..5ada9691b4 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -23,7 +23,7 @@ import { ZPublishInputSchema } from "./publish.schema"; import { ZRemoveHostsFromEventTypes } from "./removeHostsFromEventTypes.schema"; import { ZRemoveMemberInputSchema } from "./removeMember.schema"; import { ZResendInvitationInputSchema } from "./resendInvitation.schema"; -import { ZGetRoundRobinHostsToReassignInputSchema } from "./roundRobin/getRoundRobinHostsToReasign.schema"; +import { ZGetRoundRobinHostsInputSchema } from "./roundRobin/getRoundRobinHostsToReasign.schema"; import { ZRoundRobinManualReassignInputSchema } from "./roundRobin/roundRobinManualReassign.schema"; import { ZRoundRobinReassignInputSchema } from "./roundRobin/roundRobinReassign.schema"; import { ZSetInviteExpirationInputSchema } from "./setInviteExpiration.schema"; @@ -192,15 +192,13 @@ export const viewerTeamsRouter = router({ ); return handler(opts); }), - getRoundRobinHostsToReassign: authedProcedure - .input(ZGetRoundRobinHostsToReassignInputSchema) - .query(async (opts) => { - const handler = await importHandler( - namespaced("getRoundRobinHostsToReassign"), - () => import("./roundRobin/getRoundRobinHostsToReasign.handler") - ); - return handler(opts); - }), + getRoundRobinHostsToReassign: authedProcedure.input(ZGetRoundRobinHostsInputSchema).query(async (opts) => { + const handler = await importHandler( + namespaced("getRoundRobinHostsToReassign"), + () => import("./roundRobin/getRoundRobinHostsToReasign.handler") + ); + return handler(opts); + }), checkIfMembershipExists: authedProcedure .input(ZCheckIfMembershipExistsInputSchema) .mutation(async (opts) => { diff --git a/packages/trpc/server/routers/viewer/teams/roundRobin/getRoundRobinHostsToReasign.handler.ts b/packages/trpc/server/routers/viewer/teams/roundRobin/getRoundRobinHostsToReasign.handler.ts index efdc0c0b59..5d94312a2d 100644 --- a/packages/trpc/server/routers/viewer/teams/roundRobin/getRoundRobinHostsToReasign.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/roundRobin/getRoundRobinHostsToReasign.handler.ts @@ -1,10 +1,11 @@ import dayjs from "@calcom/dayjs"; import { ensureAvailableUsers } from "@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers"; -import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB"; import type { IsFixedAwareUser } from "@calcom/features/bookings/lib/handleNewBooking/types"; import { ErrorCode } from "@calcom/lib/errorCodes"; import logger from "@calcom/lib/logger"; import type { PrismaClient } from "@calcom/prisma"; +import { userSelect } from "@calcom/prisma"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import type { TGetRoundRobinHostsToReassignInputSchema } from "./getRoundRobinHostsToReasign.schema"; @@ -17,65 +18,122 @@ type GetRoundRobinHostsToReassignOptions = { input: TGetRoundRobinHostsToReassignInputSchema; }; +async function getTeamHostsFromDB({ + eventTypeId, + prisma, + searchTerm, + cursor, + limit = 20, + excludeUserId, +}: { + eventTypeId: number; + prisma: PrismaClient; + searchTerm?: string; + cursor?: number; + limit?: number; + excludeUserId?: number; +}) { + const queryWhere = { + eventTypeId, + isFixed: false, + ...(excludeUserId && { userId: { not: excludeUserId } }), + ...(searchTerm && { + user: { + OR: [ + { name: { contains: searchTerm, mode: "insensitive" as const } }, + { email: { contains: searchTerm, mode: "insensitive" as const } }, + ], + }, + }), + }; + + const [totalCount, hosts] = await Promise.all([ + prisma.host.count({ where: queryWhere }), + prisma.host.findMany({ + where: queryWhere, + select: { + isFixed: true, + priority: true, + user: { + select: { + ...userSelect.select, + credentials: { + select: credentialForCalendarServiceSelect, + }, + }, + }, + }, + take: limit + 1, // Take one more to determine if there's a next page + ...(cursor && { skip: 1, cursor: { userId_eventTypeId: { userId: cursor, eventTypeId } } }), + orderBy: [{ user: { name: "asc" } }, { priority: "desc" }], + }), + ]); + + const hasNextPage = hosts.length > limit; + const hosts_subset = hasNextPage ? hosts.slice(0, -1) : hosts; + + return { + hosts: hosts_subset.map((host) => ({ + ...host.user, + isFixed: host.isFixed, + priority: host.priority ?? 2, + })), + totalCount, + hasNextPage, + nextCursor: hasNextPage ? hosts_subset[hosts_subset.length - 1].user.id : null, + }; +} + +async function getEventTypeFromDB(eventTypeId: number, prisma: PrismaClient) { + return prisma.eventType.findUniqueOrThrow({ + where: { id: eventTypeId }, + }); +} + export const getRoundRobinHostsToReassign = async ({ ctx, input }: GetRoundRobinHostsToReassignOptions) => { const { prisma } = ctx; + const { bookingId, limit, cursor, searchTerm } = input; const gettingRoundRobinHostsToReassignLogger = logger.getSubLogger({ - prefix: ["gettingRoundRobinHostsToReassign", `${input.bookingId}`], + prefix: ["gettingRoundRobinHostsToReassign", `${bookingId}`], }); const booking = await prisma.booking.findUniqueOrThrow({ - where: { id: input.bookingId }, + where: { id: bookingId }, select: { userId: true, startTime: true, endTime: true, - eventType: { - select: { - id: true, - timeZone: true, - teamId: true, - }, - }, + eventTypeId: true, }, }); - if (!booking?.eventType) { - throw new Error("Booking not found"); + if (!booking.eventTypeId) { + throw new Error("Booking requires a event type to reassign hosts"); } - // Get event type - const eventType = await getEventTypesFromDB(booking.eventType.id); - - if (!eventType) { - throw new Error("Event type not found"); - } - - if (!eventType.teamId) { - gettingRoundRobinHostsToReassignLogger.warn("Booking with event type that has no teamId", { - bookingId: input.bookingId, - eventTypeId: eventType.id, - teamId: eventType.teamId ?? -1, - }); - return []; - } - - const availableEventTypeUsers = eventType.hosts - .filter((h) => !h.isFixed && h.user.id !== booking.userId) - .map((host) => ({ - ...host.user, - isFixed: host.isFixed, - priority: host?.priority ?? 2, - })); + const { hosts, totalCount, nextCursor } = await getTeamHostsFromDB({ + eventTypeId: booking.eventTypeId, + prisma, + searchTerm, + cursor, + limit, + excludeUserId: booking.userId ?? undefined, + }); let availableUsers: IsFixedAwareUser[] = []; try { + const eventType = await getEventTypeFromDB(booking.eventTypeId, prisma); availableUsers = await ensureAvailableUsers( - { ...eventType, users: availableEventTypeUsers as IsFixedAwareUser[] }, + // @ts-expect-error - TODO: We need to make sure nothing in the app needs the return type of getEventTypeFromDB as it fetches everything under the sun + { + users: hosts as IsFixedAwareUser[], + ...eventType, + }, { dateFrom: dayjs(booking.startTime).format(), dateTo: dayjs(booking.endTime).format(), - timeZone: eventType.timeZone || "UTC", + timeZone: "UTC", }, gettingRoundRobinHostsToReassignLogger ); @@ -84,25 +142,23 @@ export const getRoundRobinHostsToReassign = async ({ ctx, input }: GetRoundRobin availableUsers = []; } else { gettingRoundRobinHostsToReassignLogger.error(error); - // Log error and return empty array to avoid rethrowing the error - return []; } } const availableUserIds = new Set(availableUsers.map((u) => u.id)); - const roundRobinHostsToReassign = availableEventTypeUsers.reduce((acc, host) => { - const status = availableUserIds.has(host.id) ? "available" : "unavailable"; - acc.push({ - id: host.id, - name: host.name, - email: host.email, - status, - }); - return acc; - }, [] as { id: number; name: string | null; email: string; status: string }[]); + const items = hosts.map((host) => ({ + id: host.id, + name: host.name, + email: host.email, + status: availableUserIds.has(host.id) ? "available" : "unavailable", + })); - return roundRobinHostsToReassign; + return { + items, + nextCursor, + totalCount, + }; }; export default getRoundRobinHostsToReassign; diff --git a/packages/trpc/server/routers/viewer/teams/roundRobin/getRoundRobinHostsToReasign.schema.ts b/packages/trpc/server/routers/viewer/teams/roundRobin/getRoundRobinHostsToReasign.schema.ts index 3b9ad24d3b..2860a3d099 100644 --- a/packages/trpc/server/routers/viewer/teams/roundRobin/getRoundRobinHostsToReasign.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/roundRobin/getRoundRobinHostsToReasign.schema.ts @@ -1,10 +1,12 @@ import { z } from "zod"; -export const ZGetRoundRobinHostsToReassignInputSchema = z.object({ +// New schema for fetching hosts +export const ZGetRoundRobinHostsInputSchema = z.object({ bookingId: z.number(), - exclude: z.enum(["fixedHosts"]).optional(), + exclude: z.literal("fixedHosts"), + cursor: z.number().optional(), // For pagination + limit: z.number().min(1).max(100).optional(), + searchTerm: z.string().optional(), }); -export type TGetRoundRobinHostsToReassignInputSchema = z.infer< - typeof ZGetRoundRobinHostsToReassignInputSchema ->; +export type TGetRoundRobinHostsToReassignInputSchema = z.infer;