From 83d7bf322a25edc1e17ede9d2af5a25f2aebd91b Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Wed, 9 Oct 2024 22:53:45 +0530 Subject: [PATCH] fix: add missing options for team memberlist table (#17017) * fix: missing option for team memberlist table * update --- .../ee/teams/components/EventTypesList.tsx | 202 ++++++++++++++++++ .../ee/teams/components/MemberListItem.tsx | 68 +++++- .../viewer/bookings/addGuests.handler.ts | 2 +- .../server/routers/viewer/teams/_router.tsx | 16 ++ .../teams/addMembersToEventTypes.handler.ts | 42 ++++ .../teams/addMembersToEventTypes.schema.ts | 9 + .../removeHostsFromEventTypes.handler.ts | 36 ++++ .../teams/removeHostsFromEventTypes.schema.ts | 9 + 8 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 packages/features/ee/teams/components/EventTypesList.tsx create mode 100644 packages/trpc/server/routers/viewer/teams/addMembersToEventTypes.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/addMembersToEventTypes.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/removeHostsFromEventTypes.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/removeHostsFromEventTypes.schema.ts diff --git a/packages/features/ee/teams/components/EventTypesList.tsx b/packages/features/ee/teams/components/EventTypesList.tsx new file mode 100644 index 0000000000..71c0c50804 --- /dev/null +++ b/packages/features/ee/teams/components/EventTypesList.tsx @@ -0,0 +1,202 @@ +import type { Table } from "@tanstack/react-table"; +import type { Dispatch, SetStateAction } from "react"; +import { useState } from "react"; + +import classNames from "@calcom/lib/classNames"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { SchedulingType } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc"; +import { + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Popover, + showToast, + PopoverContent, + PopoverTrigger, + Icon, +} from "@calcom/ui"; + +import type { User } from "./MemberListItem"; + +interface Props { + table: Table; + teamId: number; +} + +export function EventTypesList({ table, teamId }: Props) { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const { data } = trpc.viewer.eventTypes.getByViewer.useQuery({ + filters: { teamIds: [teamId], schedulingTypes: [SchedulingType.ROUND_ROBIN] }, + }); + const addMutation = trpc.viewer.teams.addMembersToEventTypes.useMutation({ + onError: (error) => { + showToast(error.message, "error"); + }, + onSuccess: () => { + showToast( + `${selectedUsers.length} users added to ${Array.from(selectedEvents).length} events`, + "success" + ); + + utils.viewer.teams.lazyLoadMembers.invalidate(); + utils.viewer.eventTypes.invalidate(); + + // Clear the selected values + setSelectedEvents(new Set()); + table.toggleAllRowsSelected(false); + }, + }); + const removeHostsMutation = trpc.viewer.teams.removeHostsFromEventTypes.useMutation({ + onError: (error) => { + showToast(error.message, "error"); + }, + onSuccess: () => { + showToast( + `${selectedUsers.length} users were removed from ${Array.from(removeHostFromEvents).length} events`, + "success" + ); + + utils.viewer.teams.lazyLoadMembers.invalidate(); + utils.viewer.eventTypes.invalidate(); + + // Clear the selected values + setRemoveHostFromEvents(new Set()); + table.toggleAllRowsSelected(false); + }, + }); + const [selectedEvents, setSelectedEvents] = useState>(new Set()); + const [removeHostFromEvents, setRemoveHostFromEvents] = useState>(new Set()); + const eventTypeGroups = data?.eventTypeGroups; + const selectedUsers = table.getSelectedRowModel().flatRows.map((row) => row.original); + + // Add value array to the set + const addValue = (set: Set, setSet: Dispatch>>, value: number[]) => { + const updatedSet = new Set(set); + value.forEach((v) => updatedSet.add(v)); + setSet(updatedSet); + }; + + // Remove value array from the set + const removeValue = (set: Set, setSet: Dispatch>>, value: number[]) => { + const updatedSet = new Set(set); + value.forEach((v) => updatedSet.delete(v)); + setSet(updatedSet); + }; + + return ( + <> + + + + + + + + + No results found. + + {eventTypeGroups && + eventTypeGroups.map((data) => { + const events = data.eventTypes; + const teamId = data.teamId; + + if (events.length === 0 || !teamId) return null; + return ( + <> + {events.map((event) => { + const hosts = event.hosts; + const areAllUsersHostForEventType = selectedUsers.every((user) => + hosts.some((host) => host.userId === user.id) + ); + const isSelected = + (selectedEvents.has(event.id) || areAllUsersHostForEventType) && + !removeHostFromEvents.has(event.id); + return ( + { + if (!isSelected) { + if (areAllUsersHostForEventType) { + removeValue(removeHostFromEvents, setRemoveHostFromEvents, [event.id]); + } else { + // Add current event + addValue(selectedEvents, setSelectedEvents, [event.id]); + } + } else { + if (areAllUsersHostForEventType) { + // remove selected users as hosts + addValue(removeHostFromEvents, setRemoveHostFromEvents, [event.id]); + } else { + // remove current event + removeValue(selectedEvents, setSelectedEvents, [event.id]); + } + } + }} + key={event.id} + text={event.title} + isSelected={isSelected} + /> + ); + })} + + ); + })} + + + +
+ +
+
+
+ + ); +} + +interface ListItemProps { + text: string; + isSelected: boolean; + onSelect: () => void; +} + +const ListItem = ({ onSelect, text, isSelected }: ListItemProps) => { + return ( + + {text} +
+ +
+
+ ); +}; diff --git a/packages/features/ee/teams/components/MemberListItem.tsx b/packages/features/ee/teams/components/MemberListItem.tsx index 1e6e0cb022..b23cd34798 100644 --- a/packages/features/ee/teams/components/MemberListItem.tsx +++ b/packages/features/ee/teams/components/MemberListItem.tsx @@ -1,12 +1,16 @@ import { keepPreviousData } from "@tanstack/react-query"; -import type { ColumnDef } from "@tanstack/react-table"; +import type { ColumnDef, Table } from "@tanstack/react-table"; import classNames from "classnames"; +import { m } from "framer-motion"; import { signIn } from "next-auth/react"; import { useSession } from "next-auth/react"; import { useMemo, useRef, useReducer, useState, useEffect, useCallback } from "react"; import type { Dispatch, SetStateAction } from "react"; +import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useDebounce } from "@calcom/lib/hooks/useDebounce"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { MembershipRole } from "@calcom/prisma/enums"; @@ -33,6 +37,7 @@ import { import DeleteBulkTeamMembers from "./DeleteBulkTeamMembers"; import { EditMemberSheet } from "./EditMemberSheet"; +import { EventTypesList } from "./EventTypesList"; import TeamAvailabilityModal from "./TeamAvailabilityModal"; interface Props { @@ -111,8 +116,12 @@ export default function MemberListItem(props: Props) { const { data: session } = useSession(); const utils = trpc.useUtils(); const [state, dispatch] = useReducer(reducer, initialState); + const orgBranding = useOrgBranding(); + const domain = orgBranding?.fullDomain ?? WEBAPP_URL; const tableContainerRef = useRef(null); const [searchTerm, setSearchTerm] = useState(""); + const [dynamicLinkVisible, setDynamicLinkVisible] = useState(false); + const { copyToClipboard, isCopied } = useCopy(); const debouncedSearchTerm = useDebounce(searchTerm, 500); const { data, isPending, fetchNextPage, isFetching } = trpc.viewer.teams.lazyLoadMembers.useInfiniteQuery( @@ -564,6 +573,19 @@ export default function MemberListItem(props: Props) { data-testid="team-member-list-container" onSearch={(value) => setSearchTerm(value)} selectionOptions={[ + { + type: "action", + icon: "handshake", + label: "Group Meeting", + needsXSelected: 2, + onClick: () => { + setDynamicLinkVisible((old) => !old); + }, + }, + { + type: "render", + render: (table) => , + }, { type: "render", render: (table) => ( @@ -576,6 +598,50 @@ export default function MemberListItem(props: Props) { ), }, ]} + renderAboveSelection={(table: Table) => { + const numberOfSelectedRows = table.getSelectedRowModel().rows.length; + const isVisible = numberOfSelectedRows >= 2 && dynamicLinkVisible; + + const users = table + .getSelectedRowModel() + .flatRows.map((row) => row.original.username) + .filter((u) => u !== null); + + const usersNameAsString = users.join("+"); + + const dynamicLinkOfSelectedUsers = `${domain}/${usersNameAsString}`; + const domainWithoutHttps = dynamicLinkOfSelectedUsers.replace(/https?:\/\//g, ""); + + return ( + <> + {isVisible ? ( + +
+

{domainWithoutHttps}

+
+
+ + +
+
+ ) : null} + + ); + }} tableContainerRef={tableContainerRef} onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)} tableCTA={ diff --git a/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts b/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts index 99fec2cadf..2268aa2ce6 100644 --- a/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts @@ -44,7 +44,7 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => { if (!booking) throw new TRPCError({ code: "NOT_FOUND", message: "booking_not_found" }); const isTeamAdminOrOwner = - (await isTeamAdmin(user.id, booking.eventType?.teamId ?? 0)) && + (await isTeamAdmin(user.id, booking.eventType?.teamId ?? 0)) || (await isTeamOwner(user.id, booking.eventType?.teamId ?? 0)); const isOrganizer = booking.userId === user.id; diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index 2dc3bc7599..2e93506469 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -1,6 +1,7 @@ import authedProcedure from "../../../procedures/authedProcedure"; import { importHandler, router } from "../../../trpc"; import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema"; +import { ZAddMembersToEventTypes } from "./addMembersToEventTypes.schema"; import { ZChangeMemberRoleInputSchema } from "./changeMemberRole.schema"; import { ZCheckIfMembershipExistsInputSchema } from "./checkIfMembershipExists.schema"; import { ZCreateInputSchema } from "./create.schema"; @@ -20,6 +21,7 @@ import { ZGetListSchema } from "./list.schema"; import { ZListMembersInputSchema } from "./listMembers.schema"; import { hasTeamPlan } from "./procedures/hasTeamPlan"; import { ZPublishInputSchema } from "./publish.schema"; +import { ZRemoveHostsFromEventTypes } from "./removeHostsFromEventTypes.schema"; import { ZRemoveMemberInputSchema } from "./removeMember.schema"; import { ZResendInvitationInputSchema } from "./resendInvitation.schema"; import { ZRoundRobinReassignInputSchema } from "./roundRobin/roundRobinReassign.schema"; @@ -194,4 +196,18 @@ export const viewerTeamsRouter = router({ ); return handler(opts); }), + addMembersToEventTypes: authedProcedure.input(ZAddMembersToEventTypes).mutation(async (opts) => { + const handler = await importHandler( + namespaced("addMembersToEventTypes"), + () => import("./addMembersToEventTypes.handler") + ); + return handler(opts); + }), + removeHostsFromEventTypes: authedProcedure.input(ZRemoveHostsFromEventTypes).mutation(async (opts) => { + const handler = await importHandler( + namespaced("removeHostsFromEventTypes"), + () => import("./removeHostsFromEventTypes.handler") + ); + return handler(opts); + }), }); diff --git a/packages/trpc/server/routers/viewer/teams/addMembersToEventTypes.handler.ts b/packages/trpc/server/routers/viewer/teams/addMembersToEventTypes.handler.ts new file mode 100644 index 0000000000..c8fe4c63e0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/addMembersToEventTypes.handler.ts @@ -0,0 +1,42 @@ +import type { Prisma } from "@prisma/client"; + +import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams"; +import prisma from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TAddMembersToEventTypes } from "./addMembersToEventTypes.schema"; + +type AddBulkToEventTypeHandler = { + ctx: { + user: NonNullable; + }; + input: TAddMembersToEventTypes; +}; + +export async function addMembersToEventTypesHandler({ ctx, input }: AddBulkToEventTypeHandler) { + const { eventTypeIds, userIds, teamId } = input; + + const isTeamAdminOrOwner = + (await isTeamAdmin(ctx.user.id, teamId)) || (await isTeamOwner(ctx.user.id, teamId)); + + // check if user is admin or owner of team + if (!isTeamAdminOrOwner) throw new TRPCError({ code: "UNAUTHORIZED" }); + + const data: Prisma.HostCreateManyInput[] = eventTypeIds.flatMap((eventId) => + userIds.map((userId) => ({ + eventTypeId: eventId, + userId: userId, + priority: 2, // Default medium priority + })) + ); + + return await prisma.host.createMany({ + data, + // skip if user already a host in eventType + skipDuplicates: true, + }); +} + +export default addMembersToEventTypesHandler; diff --git a/packages/trpc/server/routers/viewer/teams/addMembersToEventTypes.schema.ts b/packages/trpc/server/routers/viewer/teams/addMembersToEventTypes.schema.ts new file mode 100644 index 0000000000..c9c5a53b60 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/addMembersToEventTypes.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZAddMembersToEventTypes = z.object({ + userIds: z.array(z.number()), + teamId: z.number(), + eventTypeIds: z.array(z.number()), +}); + +export type TAddMembersToEventTypes = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/removeHostsFromEventTypes.handler.ts b/packages/trpc/server/routers/viewer/teams/removeHostsFromEventTypes.handler.ts new file mode 100644 index 0000000000..c94befde9a --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/removeHostsFromEventTypes.handler.ts @@ -0,0 +1,36 @@ +import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams"; +import prisma from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TRemoveHostsFromEventTypes } from "./removeHostsFromEventTypes.schema"; + +type RemoveHostsFromEventTypes = { + ctx: { + user: NonNullable; + }; + input: TRemoveHostsFromEventTypes; +}; + +export async function removeHostsFromEventTypesHandler({ ctx, input }: RemoveHostsFromEventTypes) { + const { userIds, eventTypeIds, teamId } = input; + const isTeamAdminOrOwner = + (await isTeamAdmin(ctx.user.id, teamId)) || (await isTeamOwner(ctx.user.id, teamId)); + + // check if user is admin or owner of team + if (!isTeamAdminOrOwner) throw new TRPCError({ code: "UNAUTHORIZED" }); + + return await prisma.host.deleteMany({ + where: { + eventTypeId: { + in: eventTypeIds, + }, + userId: { + in: userIds, + }, + }, + }); +} + +export default removeHostsFromEventTypesHandler; diff --git a/packages/trpc/server/routers/viewer/teams/removeHostsFromEventTypes.schema.ts b/packages/trpc/server/routers/viewer/teams/removeHostsFromEventTypes.schema.ts new file mode 100644 index 0000000000..f00007091d --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/removeHostsFromEventTypes.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZRemoveHostsFromEventTypes = z.object({ + userIds: z.array(z.number()), + teamId: z.number(), + eventTypeIds: z.array(z.number()), +}); + +export type TRemoveHostsFromEventTypes = z.infer;