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 <sean@brydon.io>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -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 = () => {
|
||||
/>
|
||||
<Divider />
|
||||
<FilterSearchField onChange={(e) => setSearchText(e.target.value)} placeholder={t("search")} />
|
||||
|
||||
{filteredMembers?.map((member) => (
|
||||
<FilterCheckboxField
|
||||
key={member.id}
|
||||
@@ -72,6 +80,15 @@ export const PeopleFilter = () => {
|
||||
icon={<Avatar alt={`${member?.id} avatar`} imageSrc={member.avatarUrl} size="xs" />}
|
||||
/>
|
||||
))}
|
||||
<div className="text-default text-center" ref={observerRef}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={queryMembers.isFetchingNextPage}
|
||||
disabled={!queryMembers.hasNextPage}
|
||||
onClick={() => queryMembers.fetchNextPage()}>
|
||||
{queryMembers.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
</div>
|
||||
{filteredMembers?.length === 0 && (
|
||||
<h2 className="text-default px-4 py-2 text-sm font-medium">{t("no_options_available")}</h2>
|
||||
)}
|
||||
|
||||
@@ -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<BookingRedirectForm>({
|
||||
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 = ({
|
||||
</div>
|
||||
|
||||
{profileRedirect && (
|
||||
<div className="mt-4">
|
||||
<div className="h-16">
|
||||
<p className="text-emphasis block text-sm font-medium">{t("team_member")}</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="toTeamUserId"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select<Option>
|
||||
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);
|
||||
<div className="mb-2">
|
||||
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search")}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
value={searchText}
|
||||
/>
|
||||
<div className="scroll-bar flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-md border p-1">
|
||||
{memberListOptions.map((member) => (
|
||||
<label
|
||||
key={member.value}
|
||||
data-testid={`team_username_select_${member.value}`}
|
||||
tabIndex={watchedTeamUserId === member.value ? -1 : 0}
|
||||
role="radio"
|
||||
aria-checked={watchedTeamUserId === member.value}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setValue("toTeamUserId", member.value, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
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-2",
|
||||
watchedTeamUserId === member.value && "bg-subtle"
|
||||
)}>
|
||||
<div className="flex flex-1 items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
className="hidden"
|
||||
checked={watchedTeamUserId === member.value}
|
||||
onChange={() => setValue("toTeamUserId", member.value, { shouldDirty: true })}
|
||||
/>
|
||||
<span className="text-emphasis w-full px-2 text-sm">{member.label}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
<div className="text-default text-center" ref={observerRef}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={members.isFetchingNextPage}
|
||||
disabled={!members.hasNextPage}
|
||||
onClick={() => members.fetchNextPage()}>
|
||||
{members.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<number, (typeof teams)[number]["members"][number]["user"] & { accepted: boolean }>;
|
||||
// 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;
|
||||
|
||||
@@ -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<typeof ZListMembersInputSchema>;
|
||||
|
||||
Reference in New Issue
Block a user