import { keepPreviousData } from "@tanstack/react-query"; import type { ColumnDef, Table } from "@tanstack/react-table"; import { m } from "framer-motion"; import { useSession } from "next-auth/react"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { MembershipRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc"; import { Avatar, Badge, Button, Checkbox, DataTable } from "@calcom/ui"; import { useOrgBranding } from "../../../ee/organizations/context/provider"; import { DeleteBulkUsers } from "./BulkActions/DeleteBulkUsers"; import { EventTypesList } from "./BulkActions/EventTypesList"; import { TeamListBulkAction } from "./BulkActions/TeamList"; import { ChangeUserRoleModal } from "./ChangeUserRoleModal"; import { DeleteMemberModal } from "./DeleteMemberModal"; import { EditUserSheet } from "./EditSheet/EditUserSheet"; import { ImpersonationMemberModal } from "./ImpersonationMemberModal"; import { InviteMemberModal } from "./InviteMemberModal"; import { TableActions } from "./UserTableActions"; export interface User { id: number; username: string | null; email: string; timeZone: string; role: MembershipRole; avatarUrl: string | null; accepted: boolean; disableImpersonation: boolean; completedOnboarding: boolean; teams: { id: number; name: string; slug: string | null; }[]; } type Payload = { showModal: boolean; user?: User; }; export type State = { changeMemberRole: Payload; deleteMember: Payload; impersonateMember: Payload; inviteMember: Payload; editSheet: Payload; }; export type Action = | { type: | "SET_CHANGE_MEMBER_ROLE_ID" | "SET_DELETE_ID" | "SET_IMPERSONATE_ID" | "INVITE_MEMBER" | "EDIT_USER_SHEET"; payload: Payload; } | { type: "CLOSE_MODAL"; }; const initialState: State = { changeMemberRole: { showModal: false, }, deleteMember: { showModal: false, }, impersonateMember: { showModal: false, }, inviteMember: { showModal: false, }, editSheet: { showModal: false, }, }; function reducer(state: State, action: Action): State { switch (action.type) { case "SET_CHANGE_MEMBER_ROLE_ID": return { ...state, changeMemberRole: action.payload }; case "SET_DELETE_ID": return { ...state, deleteMember: action.payload }; case "SET_IMPERSONATE_ID": return { ...state, impersonateMember: action.payload }; case "INVITE_MEMBER": return { ...state, inviteMember: action.payload }; case "EDIT_USER_SHEET": return { ...state, editSheet: action.payload }; case "CLOSE_MODAL": return { ...state, changeMemberRole: { showModal: false }, deleteMember: { showModal: false }, impersonateMember: { showModal: false }, inviteMember: { showModal: false }, editSheet: { showModal: false }, }; default: return state; } } export function UserListTable() { const { data: session } = useSession(); const { copyToClipboard, isCopied } = useCopy(); const { data: org } = trpc.viewer.organizations.listCurrent.useQuery(); const { data: teams } = trpc.viewer.organizations.getTeams.useQuery(); const tableContainerRef = useRef(null); const [state, dispatch] = useReducer(reducer, initialState); const { t } = useLocale(); const orgBranding = useOrgBranding(); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); const [dynamicLinkVisible, setDynamicLinkVisible] = useState(false); const { data, isPending, fetchNextPage, isFetching } = trpc.viewer.organizations.listMembers.useInfiniteQuery( { limit: 10, searchTerm: debouncedSearchTerm, }, { getNextPageParam: (lastPage) => lastPage.nextCursor, placeholderData: keepPreviousData, } ); const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; const adminOrOwner = org?.user.role === "ADMIN" || org?.user.role === "OWNER"; const domain = orgBranding?.fullDomain ?? WEBAPP_URL; const memorisedColumns = useMemo(() => { const permissions = { canEdit: adminOrOwner, canRemove: adminOrOwner, canResendInvitation: adminOrOwner, canImpersonate: false, }; const cols: ColumnDef[] = [ // Disabling select for this PR: Will work on actions etc in a follow up { id: "select", 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, header: `Member (${totalDBRowCount})`, cell: ({ row }) => { const { username, email, avatarUrl } = row.original; return (
{username || "No username"}
{email}
); }, filterFn: (rows, id, filterValue) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore Weird typing issue return rows.getValue(id).includes(filterValue); }, }, { id: "role", accessorFn: (data) => data.role, header: "Role", cell: ({ row, table }) => { const { role, username } = row.original; return ( { table.getColumn("role")?.setFilterValue([role]); }}> {role} ); }, filterFn: (rows, id, filterValue) => { if (filterValue.includes("PENDING")) { if (filterValue.length === 1) return !rows.original.accepted; else return !rows.original.accepted || filterValue.includes(rows.getValue(id)); } // Show only the selected roles return filterValue.includes(rows.getValue(id)); }, }, { id: "teams", accessorFn: (data) => data.teams.map((team) => team.name), header: "Teams", cell: ({ row, table }) => { const { teams, accepted, email, username } = row.original; // TODO: Implement click to filter return (
{accepted ? null : ( { table.getColumn("role")?.setFilterValue(["PENDING"]); }}> Pending )} {teams.map((team) => ( { table.getColumn("teams")?.setFilterValue([team.name]); }}> {team.name} ))}
); }, filterFn: (rows, _, filterValue: string[]) => { const teamNames = rows.original.teams.map((team) => team.name); return filterValue.some((value: string) => teamNames.includes(value)); }, }, { id: "actions", cell: ({ row }) => { const user = row.original; const permissionsRaw = permissions; const isSelf = user.id === session?.user.id; const permissionsForUser = { canEdit: permissionsRaw.canEdit && user.accepted && !isSelf, canRemove: permissionsRaw.canRemove && !isSelf, canImpersonate: user.accepted && !user.disableImpersonation && !isSelf && !!org?.canAdminImpersonate, canLeave: user.accepted && isSelf, canResendInvitation: permissionsRaw.canResendInvitation && !user.accepted, }; return ( ); }, }, ]; return cols; }, [session?.user.id, adminOrOwner, dispatch, domain, totalDBRowCount]); //we must flatten the array of arrays from the useInfiniteQuery hook const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as User[]; const totalFetched = flatData.length; //called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table const fetchMoreOnBottomReached = useCallback( (containerRefElement?: HTMLDivElement | null) => { if (containerRefElement) { const { scrollHeight, scrollTop, clientHeight } = containerRefElement; //once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) { fetchNextPage(); } } }, [fetchNextPage, isFetching, totalFetched, totalDBRowCount] ); useEffect(() => { fetchMoreOnBottomReached(tableContainerRef.current); }, [fetchMoreOnBottomReached]); return ( <> setDebouncedSearchTerm(value)} selectionOptions={[ { type: "render", render: (table) => , }, { type: "action", icon: "handshake", label: "Group Meeting", needsXSelected: 2, onClick: () => { setDynamicLinkVisible((old) => !old); }, }, { type: "render", render: (table) => , }, { type: "render", render: (table) => ( row.original)} onRemove={() => table.toggleAllPageRowsSelected(false)} /> ), }, ]} 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} tableCTA={ adminOrOwner && ( ) } columns={memorisedColumns} data={flatData} isPending={isPending} onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)} filterableItems={[ { tableAccessor: "role", title: "Role", options: [ { label: "Owner", value: "OWNER" }, { label: "Admin", value: "ADMIN" }, { label: "Member", value: "MEMBER" }, { label: "Pending", value: "PENDING" }, ], }, { tableAccessor: "teams", title: "Teams", options: teams ? teams.map((team) => ({ label: team.name, value: team.name })) : [], }, ]} /> {state.deleteMember.showModal && } {state.inviteMember.showModal && } {state.impersonateMember.showModal && } {state.changeMemberRole.showModal && } {state.editSheet.showModal && } ); }