"use client"; import { keepPreviousData } from "@tanstack/react-query"; import { getCoreRowModel, getFilteredRowModel, getSortedRowModel, getFacetedUniqueValues, useReactTable, type ColumnDef, } from "@tanstack/react-table"; import classNames from "classnames"; import { useSession } from "next-auth/react"; import { signIn } from "next-auth/react"; import { useQueryState, parseAsBoolean } from "nuqs"; import { useMemo, useReducer, useRef, useState } from "react"; import type { Dispatch, SetStateAction } from "react"; import { DataTable, DataTableProvider, DataTableToolbar, DataTableFilters, DataTableSelectionBar, useFetchMoreOnBottomReached, useColumnFilters, } from "@calcom/features/data-table"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { DynamicLink } from "@calcom/features/users/components/UserTable/BulkActions/DynamicLink"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { MembershipRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc"; import type { RouterOutputs } from "@calcom/trpc/react"; import { Avatar, Badge, Button, ButtonGroup, Checkbox, ConfirmationDialogContent, Dialog, DialogClose, DialogContent, DialogFooter, Dropdown, DropdownItem, DropdownMenuPortal, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, showToast, Tooltip, } from "@calcom/ui"; import DeleteBulkTeamMembers from "./DeleteBulkTeamMembers"; import { EditMemberSheet } from "./EditMemberSheet"; import { EventTypesList } from "./EventTypesList"; import TeamAvailabilityModal from "./TeamAvailabilityModal"; interface Props { team: NonNullable; isOrgAdminOrOwner: boolean | undefined; setShowMemberInvitationModal: Dispatch>; } export type User = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number]; const checkIsOrg = (team: Props["team"]) => { return team.isOrganization; }; type Payload = { showModal: boolean; user?: User; }; export type State = { deleteMember: Payload; impersonateMember: Payload; editSheet: Payload; teamAvailability: Payload; }; export type Action = | { type: | "SET_DELETE_ID" | "SET_IMPERSONATE_ID" | "EDIT_USER_SHEET" | "TEAM_AVAILABILITY" | "INVITE_MEMBER"; payload: Payload; } | { type: "CLOSE_MODAL"; }; const initialState: State = { deleteMember: { showModal: false, }, impersonateMember: { showModal: false, }, editSheet: { showModal: false, }, teamAvailability: { showModal: false, }, }; const initalColumnVisibility = { select: true, member: true, role: true, teams: true, actions: true, }; function reducer(state: State, action: Action): State { switch (action.type) { case "SET_DELETE_ID": return { ...state, deleteMember: action.payload }; case "SET_IMPERSONATE_ID": return { ...state, impersonateMember: action.payload }; case "EDIT_USER_SHEET": return { ...state, editSheet: action.payload }; case "TEAM_AVAILABILITY": return { ...state, teamAvailability: action.payload }; case "CLOSE_MODAL": return { ...state, deleteMember: { showModal: false }, impersonateMember: { showModal: false }, editSheet: { showModal: false }, teamAvailability: { showModal: false }, }; default: return state; } } interface Props { team: NonNullable; isOrgAdminOrOwner: boolean | undefined; setShowMemberInvitationModal: Dispatch>; } export default function MemberList(props: Props) { return ( ); } function MemberListContent(props: Props) { const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean); const { t, i18n } = useLocale(); const { data: session } = useSession(); const utils = trpc.useUtils(); const orgBranding = useOrgBranding(); const domain = orgBranding?.fullDomain ?? WEBAPP_URL; const tableContainerRef = useRef(null); const [state, dispatch] = useReducer(reducer, initialState); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); const { data, isPending, hasNextPage, fetchNextPage, isFetching } = trpc.viewer.teams.listMembers.useInfiniteQuery( { limit: 10, searchTerm: debouncedSearchTerm, teamId: props.team.id, // TODO: send `columnFilters` to server for server side filtering // filters: columnFilters, }, { enabled: !!props.team.id, getNextPageParam: (lastPage) => lastPage.nextCursor, placeholderData: keepPreviousData, refetchOnWindowFocus: true, refetchOnMount: true, staleTime: 0, } ); const columnFilters = useColumnFilters(); const [rowSelection, setRowSelection] = useState({}); const removeMemberFromCache = ({ utils, memberId, teamId, searchTerm, }: { utils: ReturnType; memberId: number; teamId: number; searchTerm: string; }) => { utils.viewer.teams.listMembers.setInfiniteData( { limit: 10, teamId, searchTerm, }, (data) => { if (!data) { return { pages: [], pageParams: [], }; } return { ...data, pages: data.pages.map((page) => ({ ...page, members: page.members.filter((member) => member.id !== memberId), })), }; } ); }; const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({ onMutate: async ({ teamIds }) => { await utils.viewer.teams.listMembers.cancel(); const previousValue = utils.viewer.teams.listMembers.getInfiniteData({ limit: 10, teamId: teamIds[0], searchTerm: debouncedSearchTerm, }); if (previousValue) { removeMemberFromCache({ utils, memberId: state.deleteMember.user?.id as number, teamId: teamIds[0], searchTerm: debouncedSearchTerm, }); } return { previousValue }; }, async onSuccess() { await utils.viewer.teams.get.invalidate(); await utils.viewer.eventTypes.invalidate(); await utils.viewer.organizations.listMembers.invalidate(); await utils.viewer.organizations.getMembers.invalidate(); showToast(t("success"), "success"); }, async onError(err) { showToast(err.message, "error"); }, }); const resendInvitationMutation = trpc.viewer.teams.resendInvitation.useMutation({ onSuccess: () => { showToast(t("invitation_resent"), "success"); }, onError: (error) => { showToast(error.message, "error"); }, }); // const ownersInTeam = () => { // const { members } = props.team; // const owners = members.filter((member) => member["role"] === MembershipRole.OWNER && member["accepted"]); // return owners.length; // }; const isAdminOrOwner = props.team.membership.role === MembershipRole.OWNER || props.team.membership.role === MembershipRole.ADMIN; const removeMember = () => removeMemberMutation.mutate({ teamIds: [props.team?.id], memberIds: [state.deleteMember.user?.id as number], isOrg: checkIsOrg(props.team), }); const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; const memorisedColumns = useMemo(() => { const cols: ColumnDef[] = [ // Disabling select for this PR: Will work on actions etc in a follow up { id: "select", enableHiding: false, enableSorting: false, enableResizing: false, size: 30, header: ({ table }) => ( table.toggleAllPageRowsSelected(!!value)} aria-label="Select all" className="translate-y-[2px]" /> ), cell: ({ row }) => ( row.toggleSelected(!!value)} aria-label="Select row" className="translate-y-[2px]" /> ), }, { id: "member", accessorFn: (data) => data.email, enableHiding: false, header: "Member", size: 250, cell: ({ row }) => { const { username, email, avatarUrl, accepted, name } = row.original; const memberName = name || (() => { const emailName = email.split("@")[0]; return emailName.charAt(0).toUpperCase() + emailName.slice(1); })(); return (
{memberName}
{email}
); }, filterFn: (rows, id, filterValue) => { const { data } = filterValue; const userEmail = rows.original.email; return data.includes(userEmail); }, }, { id: "role", accessorFn: (data) => data.role, header: "Role", size: 100, cell: ({ row, table }) => { const { role, accepted } = row.original; return (
{!accepted && ( { table.getColumn("role")?.setFilterValue(["PENDING"]); }}> Pending )} { table.getColumn("role")?.setFilterValue([role]); }}> {role}
); }, filterFn: (rows, id, filterValue) => { const { data } = filterValue; if (data.includes("PENDING")) { if (data.length === 1) return !rows.original.accepted; else return !rows.original.accepted || data.includes(rows.getValue(id)); } // Show only the selected roles return data.includes(rows.getValue(id)); }, }, { id: "lastActiveAt", header: "Last Active", cell: ({ row }) =>
{row.original.lastActiveAt}
, }, { id: "actions", size: 90, enableResizing: false, cell: ({ row }) => { const user = row.original; const isSelf = user.id === session?.user.id; const editMode = (props.team.membership?.role === MembershipRole.OWNER && (user.role !== MembershipRole.OWNER || !isSelf)) || (props.team.membership?.role === MembershipRole.ADMIN && user.role !== MembershipRole.OWNER) || props.isOrgAdminOrOwner; const impersonationMode = editMode && !user.disableImpersonation && user.accepted && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true"; const resendInvitation = editMode && !user.accepted; return ( <> {props.team.membership?.accepted && (
)} ); }, }, ]; return cols; }, [props.isOrgAdminOrOwner, dispatch, totalDBRowCount, session?.user.id]); //we must flatten the array of arrays from the useInfiniteQuery hook const flatData = useMemo(() => data?.pages?.flatMap((page) => page.members) ?? [], [data]) as User[]; const table = useReactTable({ data: flatData, columns: memorisedColumns, enableRowSelection: true, debugTable: true, manualPagination: true, initialState: { columnVisibility: initalColumnVisibility, columnPinning: { right: ["actions"], }, }, state: { columnFilters, rowSelection, }, onRowSelectionChange: setRowSelection, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), getRowId: (row) => `${row.id}`, }); const fetchMoreOnBottomReached = useFetchMoreOnBottomReached({ tableContainerRef, hasNextPage, fetchNextPage, isFetching, }); const numberOfSelectedRows = table.getSelectedRowModel().rows.length; return ( <> fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
setDebouncedSearchTerm(value)} /> {isAdminOrOwner && ( props.setShowMemberInvitationModal(true)} data-testid="new-member-button"> {t("add")} )}
{numberOfSelectedRows >= 2 && dynamicLinkVisible && ( )} {numberOfSelectedRows > 0 && (

{t("number_selected", { count: numberOfSelectedRows })}

{numberOfSelectedRows >= 2 && ( setDynamicLinkVisible(!dynamicLinkVisible)} icon="handshake"> {t("group_meeting")} )} row.original)} onRemove={() => table.toggleAllPageRowsSelected(false)} isOrg={checkIsOrg(props.team)} teamId={props.team.id} />
)}
{state.deleteMember.showModal && ( !open && dispatch({ type: "CLOSE_MODAL", }) }> {t("remove_member_confirmation_message")} )} {state.impersonateMember.showModal && state.impersonateMember.user?.username && ( dispatch({ type: "CLOSE_MODAL", }) }>
{ e.preventDefault(); await signIn("impersonation-auth", { username: state.impersonateMember.user?.email, teamId: props.team.id, }); dispatch({ type: "CLOSE_MODAL", }); }}> {t("cancel")}
)} {state.teamAvailability.showModal && ( { dispatch({ type: "CLOSE_MODAL", }); }}> )} {state.editSheet.showModal && ( )} ); }