"use client"; import process from "node:process"; import { Dialog } from "@calcom/features/components/controlled-dialog"; import { convertFacetedValuesToMap } from "@calcom/features/data-table"; import { DataTableProvider } from "~/data-table/DataTableProvider"; import { useDataTable } from "~/data-table/hooks/useDataTable"; import { useFetchMoreOnBottomReached } from "~/data-table/hooks/useFetchMoreOnBottomReached"; import { useColumnFilters } from "~/data-table/hooks/useColumnFilters"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useFillRemainingHeight } from "@calcom/lib/hooks/useFillRemainingHeight"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import { Avatar } from "@calcom/ui/components/avatar"; import { Badge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; import { ButtonGroup } from "@calcom/ui/components/buttonGroup"; import { ConfirmationDialogContent, DialogClose, DialogContent, DialogFooter, } from "@calcom/ui/components/dialog"; import { Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuTrigger, } from "@calcom/ui/components/dropdown"; import { Checkbox } from "@calcom/ui/components/form"; import { showToast } from "@calcom/ui/components/toast"; import { Tooltip } from "@calcom/ui/components/tooltip"; import TeamAvailabilityModal from "@calcom/web/modules/ee/teams/components/TeamAvailabilityModal"; import { DynamicLink } from "@calcom/web/modules/users/components/UserTable/BulkActions/DynamicLink"; import { keepPreviousData } from "@tanstack/react-query"; import { type ColumnDef, getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table"; import classNames from "classnames"; import { usePathname } from "next/navigation"; import { signIn, useSession } from "next-auth/react"; import { parseAsBoolean, useQueryState } from "nuqs"; import posthog from "posthog-js"; import type { Dispatch, SetStateAction } from "react"; import { useMemo, useReducer, useRef, useState } from "react"; import { DataTableFilters, DataTableSelectionBar, DataTableToolbar, DataTableWrapper, } from "~/data-table/components"; import DeleteBulkTeamMembers from "./DeleteBulkTeamMembers"; import { EditMemberSheet } from "./EditMemberSheet"; import { EventTypesList } from "./EventTypesList"; 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>; facetedTeamValues?: { roles: { id: string; name: string }[]; teams: RouterOutputs["viewer"]["teams"]["get"][]; attributes: { id: string; name: string; options: { value: string; }[]; }[]; }; permissions: MemberPermissions; } export default function MemberList(props: Props) { const pathname = usePathname(); if (!pathname) return null; return ( ); } function MemberListContent(props: Props) { const { facetedTeamValues } = 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 { searchTerm } = useDataTable(); const { data, isPending, hasNextPage, fetchNextPage, isFetching } = trpc.viewer.teams.listMembers.useInfiniteQuery( { limit: 10, searchTerm, 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, }); if (previousValue) { removeMemberFromCache({ utils, memberId: state.deleteMember.user?.id as number, teamId: teamIds[0], searchTerm, }); } 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 removeMember = () => removeMemberMutation.mutate({ teamIds: [props.team?.id], memberIds: [state.deleteMember.user?.id as number], isOrg: checkIsOrg(props.team), }); const totalRowCount = 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" /> ), 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, customRole } = row.original; const roleName = customRole?.name || role; const roleIdentifier = customRole?.id || role; return (
{!accepted && ( { table.getColumn("role")?.setFilterValue(["PENDING"]); }}> Pending )} { table.getColumn("role")?.setFilterValue([roleIdentifier]); }}> {roleName}
); }, filterFn: (rows, id, filterValue) => { const { data } = filterValue; const { role, accepted, customRole } = rows.original; const roleIdentifier = customRole?.id || role; if (data.includes("PENDING")) { if (data.length === 1) return !accepted; else return !accepted || data.includes(roleIdentifier); } // Show only the selected roles (check both traditional role and custom role ID) return data.includes(roleIdentifier); }, }, { 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; // TODO(SEAN) In a follow up can we rename canChangeMemberRole to canEditMembers - role is a bit specific. const canChangeRole = props.permissions?.canChangeMemberRole ?? false; const canRemove = props.permissions?.canRemove ?? false; const canImpersonate = props.permissions?.canImpersonate ?? false; const canResendInvitation = props.permissions?.canInvite ?? false; const editMode = [canChangeRole, canRemove, canImpersonate, canResendInvitation].some(Boolean) && !isSelf; const impersonationMode = canImpersonate && !user.disableImpersonation && user.accepted && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true"; return ( <> {props.team.membership?.accepted && (
)} ); }, }, ]; return cols; }, [props.isOrgAdminOrOwner, dispatch, totalRowCount, 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: (_, columnId) => () => { if (facetedTeamValues) { switch (columnId) { case "role": { // Include both traditional roles and PBAC custom roles const allRoles = facetedTeamValues.roles.map((role) => ({ label: role.name, value: role.id, })); return convertFacetedValuesToMap(allRoles); } default: return new Map(); } } return new Map(); }, getRowId: (row) => `${row.id}`, }); const numberOfSelectedRows = table.getSelectedRowModel().rows.length; return ( <> } ToolbarRight={ <> {props.permissions.canInvite && ( { props.setShowMemberInvitationModal(true); posthog.capture("teams_add_new_members_button_clicked"); }} 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 && ( )} ); }