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
This commit is contained in:
sean-brydon
2024-10-29 17:47:43 +00:00
committed by GitHub
parent 1a55f2d8e2
commit 55671db5fd
9 changed files with 226 additions and 123 deletions
+100 -53
View File
@@ -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<FormValues>({
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 form={form} handleSubmit={handleSubmit} ref={animationParentRef}>
<RadioArea.Group
onValueChange={(val) => {
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">
<RadioArea.Item
value="round_robin"
className={classNames("w-full text-sm")}
classNames={{ container: classNames("w-full") }}>
className="w-full text-sm"
classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_reassign_description")}</p>
</RadioArea.Item>
<RadioArea.Item
value="team_member"
className={classNames("text-sm")}
classNames={{ container: classNames("w-full") }}>
<RadioArea.Item value="team_member" className="text-sm" classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("team_member_round_robin_reassign")}</strong>
<p>{t("team_member_round_robin_reassign_description")}</p>
</RadioArea.Item>
@@ -176,31 +192,62 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
{watchedReassignType === "team_member" && (
<div className="mb-2">
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
<Select
value={teamMemberOptions?.find((option) => option.value === form.getValues("teamMemberId"))}
options={teamMemberOptions}
onChange={(event) => {
form.setValue("teamMemberId", event?.value);
}}
components={{
Option: ({ children, ...props }) => {
const status = props.data?.status;
return (
<components.Option {...props}>
<div className="flex items-center gap-2">
<div
className={classNames(
"h-2 w-2 rounded-full",
status === "available" ? "bg-green-500" : "bg-red-500"
)}
/>
{children}
</div>
</components.Option>
);
},
}}
/>
<div className="mt-2">
<Input
type="text"
placeholder={t("search")}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="scroll-bar flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-md border p-1">
{teamMemberOptions.map((member) => (
<label
key={member.value}
tabIndex={watchedTeamMemberId === member.value ? -1 : 0}
role="radio"
aria-checked={watchedTeamMemberId === member.value}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
form.setValue("teamMemberId", member.value);
}
}}
className={classNames(
"hover:bg-subtle focus:bg-subtle focus:ring-emphasis cursor-pointer items-center justify-between gap-0.5 rounded-sm py-2 outline-none focus:ring focus:ring-2",
watchedTeamMemberId === member.value && "bg-subtle"
)}>
<div className="flex flex-1 items-center space-x-3">
<input
type="radio"
className="hidden"
checked={watchedTeamMemberId === member.value}
onChange={() => form.setValue("teamMemberId", member.value)}
/>
<div
className={classNames(
"h-3 w-3 flex-shrink-0 rounded-full",
member.status === "unavailable" ? "bg-red-500" : "bg-green-500"
)}
/>
<span className="text-emphasis w-full text-sm">{member.label}</span>
{watchedTeamMemberId === member.value && (
<div className="place-self-end pr-2">
<Icon name="check" className="text-emphasis h-4 w-4" />
</div>
)}
</div>
</label>
))}
<div className="text-default text-center" ref={observerRef}>
<Button
color="minimal"
loading={isFetchingNextPage}
disabled={!hasNextPage}
onClick={() => fetchNextPage()}>
{hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</div>
</div>
</div>
)}
<DialogFooter>
@@ -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";
@@ -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"];
@@ -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
<div className="w-full max-w-[2000px] overflow-x-scroll">
{!isPending && (
<div className="text-default text-md mx-4 mb-2">
{`${numberOfRows} ` + (numberOfRows === 1 ? t("row") : t("rows"))}
{`${numberOfRows} ${numberOfRows === 1 ? t("row") : t("rows")}`}
</div>
)}
<table
@@ -189,4 +189,5 @@ export function SelectWithValidation<
</div>
);
}
export default Select;
@@ -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) => {
@@ -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;
@@ -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<typeof ZGetRoundRobinHostsInputSchema>;