From ebd5ca6cebefdd90baae7b0ce2a398978beb639a Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Thu, 31 Oct 2024 20:27:07 +0530 Subject: [PATCH] fix: people filter in /bookings and create OOO modal (#17396) * fix: people filter in /bookings * refactor: lazy loading people filters and OOO * chore: type errr * chore: type errr * fallback to username if no name is set --------- Co-authored-by: sean --- apps/web/playwright/out-of-office.e2e.ts | 8 +- apps/web/test-results/.last-run.json | 4 + .../bookings/components/PeopleFilter.tsx | 41 ++++-- .../CreateOrEditOutOfOfficeModal.tsx | 98 ++++++++++---- .../routers/viewer/bookings/get.handler.ts | 2 + .../viewer/teams/legacyListMembers.handler.ts | 120 +++++++++++++----- .../viewer/teams/legacyListMembers.schema.ts | 3 + 7 files changed, 201 insertions(+), 75 deletions(-) create mode 100644 apps/web/test-results/.last-run.json diff --git a/apps/web/playwright/out-of-office.e2e.ts b/apps/web/playwright/out-of-office.e2e.ts index d16fedaab3..41bfe24554 100644 --- a/apps/web/playwright/out-of-office.e2e.ts +++ b/apps/web/playwright/out-of-office.e2e.ts @@ -79,9 +79,7 @@ test.describe("Out of office", () => { await page.getByTestId("profile-redirect-switch").click(); - await page.getByTestId("team_username_select").click(); - - await page.locator("#react-select-3-option-0").click(); + await page.getByTestId(`team_username_select_${userTo.id}`).click(); // send request await saveAndWaitForResponse(page); @@ -157,9 +155,7 @@ test.describe("Out of office", () => { await page.getByTestId("notes_input").click(); await page.getByTestId("notes_input").fill("Changed notes"); - await page.getByTestId("team_username_select").click(); - - await page.locator("#react-select-3-option-1").click(); + await page.getByTestId(`team_username_select_${userToSecond.id}`).click(); // send request await saveAndWaitForResponse(page); diff --git a/apps/web/test-results/.last-run.json b/apps/web/test-results/.last-run.json new file mode 100644 index 0000000000..544c11fbc3 --- /dev/null +++ b/apps/web/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} diff --git a/packages/features/bookings/components/PeopleFilter.tsx b/packages/features/bookings/components/PeopleFilter.tsx index 92045d573c..527d45a586 100644 --- a/packages/features/bookings/components/PeopleFilter.tsx +++ b/packages/features/bookings/components/PeopleFilter.tsx @@ -1,18 +1,18 @@ import { useState } from "react"; import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery"; -import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { FilterCheckboxField, FilterCheckboxFieldsContainer, } from "@calcom/features/filters/components/TeamsFilter"; +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 { AnimatedPopover, Avatar, Divider, FilterSearchField, Icon } from "@calcom/ui"; +import { AnimatedPopover, Avatar, Divider, FilterSearchField, Icon, Button } from "@calcom/ui"; export const PeopleFilter = () => { const { t } = useLocale(); - const orgBranding = useOrgBranding(); const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(); const isAdmin = currentOrg?.user.role === "ADMIN" || currentOrg?.user.role === "OWNER"; @@ -21,16 +21,23 @@ export const PeopleFilter = () => { const { data: query, pushItemToKey, removeItemByKeyAndValue, removeAllQueryParams } = useFilterQuery(); const [searchText, setSearchText] = useState(""); - const members = trpc.viewer.teams.legacyListMembers.useQuery({}); + const debouncedSearch = useDebounce(searchText, 500); - const filteredMembers = members?.data - ?.filter((member) => member.accepted) - ?.filter((member) => - searchText.trim() !== "" - ? member?.name?.toLowerCase()?.includes(searchText.toLowerCase()) || - member?.username?.toLowerCase()?.includes(searchText.toLowerCase()) - : true - ); + const queryMembers = trpc.viewer.teams.legacyListMembers.useInfiniteQuery( + { limit: 10, searchText: debouncedSearch }, + { + enabled: true, + getNextPageParam: (lastPage) => lastPage.nextCursor, + } + ); + + const { ref: observerRef } = useInViewObserver(() => { + if (queryMembers.hasNextPage && !queryMembers.isFetching) { + queryMembers.fetchNextPage(); + } + }, document.querySelector('[role="dialog"]')); + + const filteredMembers = queryMembers?.data?.pages.flatMap((page) => page.members); const getTextForPopover = () => { const userIds = query.userIds; @@ -56,6 +63,7 @@ export const PeopleFilter = () => { /> setSearchText(e.target.value)} placeholder={t("search")} /> + {filteredMembers?.map((member) => ( { icon={} /> ))} +
+ +
{filteredMembers?.length === 0 && (

{t("no_options_available")}

)} diff --git a/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx b/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx index 4fc420b61a..15d884cf9c 100644 --- a/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx +++ b/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx @@ -2,7 +2,10 @@ import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; import dayjs from "@calcom/dayjs"; +import { classNames } from "@calcom/lib"; +import { useDebounce } from "@calcom/lib/hooks/useDebounce"; import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan"; +import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; @@ -18,6 +21,8 @@ import { Switch, TextArea, UpgradeTeamsBadge, + Label, + Input, } from "@calcom/ui"; export type BookingRedirectForm = { @@ -43,21 +48,39 @@ export const CreateOrEditOutOfOfficeEntryModal = ({ const { t } = useLocale(); const utils = trpc.useUtils(); - const { data: listMembers } = trpc.viewer.teams.legacyListMembers.useQuery({}); + const [searchText, setSearchText] = useState(""); + const debouncedSearch = useDebounce(searchText, 500); + + const members = trpc.viewer.teams.legacyListMembers.useInfiniteQuery( + { limit: 10, searchText: debouncedSearch }, + { + enabled: true, + getNextPageParam: (lastPage) => lastPage.nextCursor, + } + ); const me = useMeQuery(); const memberListOptions: { value: number; label: string; + avatarUrl: string | null; }[] = - listMembers + members?.data?.pages + .flatMap((page) => page.members) ?.filter((member) => me?.data?.id !== member.id) .map((member) => ({ value: member.id, - label: member.name || "", + label: member.name || member.username || "", + avatarUrl: member.avatarUrl, })) || []; + const { ref: observerRef } = useInViewObserver(() => { + if (members.hasNextPage && !members.isFetching) { + members.fetchNextPage(); + } + }, document.querySelector('[role="dialog"]')); + const { data: outOfOfficeReasonList } = trpc.viewer.outOfOfficeReasonList.useQuery(); const reasonList = (outOfOfficeReasonList || []).map((reason) => ({ @@ -74,6 +97,7 @@ export const CreateOrEditOutOfOfficeEntryModal = ({ setValue, control, register, + watch, formState: { isSubmitting }, } = useForm({ defaultValues: currentlyEditingOutOfOfficeEntry @@ -89,6 +113,8 @@ export const CreateOrEditOutOfOfficeEntryModal = ({ }, }); + const watchedTeamUserId = watch("toTeamUserId"); + const createOrEditOutOfOfficeEntry = trpc.viewer.outOfOfficeCreateOrUpdate.useMutation({ onSuccess: () => { showToast( @@ -209,28 +235,54 @@ export const CreateOrEditOutOfOfficeEntryModal = ({ {profileRedirect && ( -
-
-

{t("team_member")}

- ( - - name="toTeamUsername" - data-testid="team_username_select" - value={memberListOptions.find((member) => member.value === value)} - placeholder={t("select_team_member")} - isSearchable - options={memberListOptions} - onChange={(selectedOption) => { - if (selectedOption?.value) { - onChange(selectedOption.value); +
+ +
+ setSearchText(e.target.value)} + value={searchText} + /> +
+ {memberListOptions.map((member) => ( + + ))} +
+ +
+
)} diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index c056d06efd..b76472c999 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -114,6 +114,7 @@ export async function getBookings({ userId: { in: filters.userIds, }, + isFixed: true, }, }, }, @@ -207,6 +208,7 @@ export async function getBookings({ .map((key) => bookingWhereInputFilters[key]) // On prisma 5.4.2 passing undefined to where "AND" causes an error .filter(Boolean); + const bookingSelect = { ...bookingMinimalSelect, uid: true, diff --git a/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts index 7341f2acb9..6f0b9ef368 100644 --- a/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts @@ -18,52 +18,104 @@ export const legacyListMembers = async ({ ctx, input }: ListMembersOptions) => { const hasPermsToView = !ctx.user.organization.isPrivate || isOrgAdmin; if (!hasPermsToView) { - return []; + return { + members: [], + nextCursor: undefined, + }; } - const teams = await prisma.team.findMany({ + const limit = input.limit ?? 10; + const cursor = input.cursor ?? 0; + + let teamsToQuery = input.teamIds; + + // If no teamIds are provided, we query all teams the user is a member of + if (!input?.teamIds?.length) { + const memberships = await prisma.membership.findMany({ + where: { + userId: ctx.user.id, + accepted: true, + }, + select: { teamId: true }, + }); + teamsToQuery = memberships.map((m) => m.teamId); + } else { + const memberships = await prisma.membership.findMany({ + where: { + teamId: { in: input.teamIds }, + userId: ctx.user.id, + accepted: true, + }, + }); + teamsToQuery = memberships.map((m) => m.teamId); + } + + if (!teamsToQuery.length) { + return { + members: [], + nextCursor: undefined, + }; + } + + // Fetch unique users through memberships + const memberships = await prisma.membership.findMany({ where: { - id: { - in: input.teamIds, - }, - members: { - some: { - user: { - id: ctx.user.id, - }, - accepted: true, - }, - }, + accepted: true, + teamId: { in: teamsToQuery }, + user: input.searchText?.trim()?.length + ? { + OR: [ + { name: { contains: input.searchText, mode: "insensitive" } }, + { username: { contains: input.searchText, mode: "insensitive" } }, + ], + } + : undefined, }, select: { - members: { + id: true, + accepted: true, + user: { select: { - user: { - select: { - id: true, - name: true, - username: true, - avatarUrl: true, - }, - }, - accepted: true, + id: true, + name: true, + username: true, + avatarUrl: true, }, }, }, + distinct: ["userId"], + cursor: cursor ? { id: cursor } : undefined, + take: limit + 1, + orderBy: [ + { userId: "asc" }, // First order by userId to ensure consistent ordering + { id: "asc" }, // Then by id as secondary sort + ], }); - type UserMap = Record; - // flatten users to be unique by id - const users = teams - .flatMap((t) => t.members) - .reduce( - (acc, m) => (m.user.id in acc ? acc : { ...acc, [m.user.id]: { ...m.user, accepted: m.accepted } }), - {} as UserMap - ); - - return await Promise.all( - Object.values(users).map(async (u) => UserRepository.enrichUserWithItsProfile({ user: u })) + const enrichedMembers = await Promise.all( + memberships.map(async (membership) => + UserRepository.enrichUserWithItsProfile({ + user: { + ...membership.user, + accepted: membership.accepted, + membershipId: membership.id, + }, + }) + ) ); + + const usersFetched = enrichedMembers.length; + + let nextCursor: typeof cursor | undefined = undefined; + if (usersFetched > limit) { + const nextItem = enrichedMembers.pop(); + nextCursor = nextItem?.membershipId; + } + + return { + members: enrichedMembers, + nextCursor, + }; }; export default legacyListMembers; diff --git a/packages/trpc/server/routers/viewer/teams/legacyListMembers.schema.ts b/packages/trpc/server/routers/viewer/teams/legacyListMembers.schema.ts index ff83f6c100..62f24b551b 100644 --- a/packages/trpc/server/routers/viewer/teams/legacyListMembers.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/legacyListMembers.schema.ts @@ -2,6 +2,9 @@ import { z } from "zod"; export const ZListMembersInputSchema = z.object({ teamIds: z.number().array().optional(), + searchText: z.string().optional(), + limit: z.number().min(1).max(100).nullish(), + cursor: z.number().nullish(), }); export type TListMembersInputSchema = z.infer;