diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts index e36b6c64d4..858fba51f9 100644 --- a/apps/web/playwright/organization/organization-invitation.e2e.ts +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -543,9 +543,15 @@ async function expectUserToBeAMemberOfTeam({ await page.waitForLoadState("domcontentloaded"); expect( ( - await page.locator(`[data-testid="member-${username}"] [data-testid=member-role]`).textContent() + await page + .locator( + `[data-testid="member-${username}"] [data-testid="${ + isMemberShipAccepted ? "member-email" : `email-${email.replace("@", "")}-pending` + }"]` + ) + .textContent() )?.toLowerCase() - ).toBe(role.toLowerCase()); + ).toBe(email.toLowerCase()); if (isMemberShipAccepted) { await expect(page.locator(`[data-testid="email-${email.replace("@", "")}-pending"]`)).toBeHidden(); } else { diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 8b26870690..ce5b359fbf 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2177,8 +2177,11 @@ "add_to_team": "Add to team", "remove_users_from_org": "Remove users from organization", "remove_users_from_org_confirm": "Are you sure you want to remove {{userCount}} users from this organization?", + "remove_users_from_team": "Remove users from team", + "remove_users_from_team_confirm": "Are you sure you want to remove {{userCount}} users from this team?", "user_has_no_schedules": "This user has not setup any schedules yet", "user_isnt_in_any_teams": "This user is not in any teams", + "user_has_no_app_installed": "This user has not installed any apps yet", "requires_booker_email_verification": "Requires booker email verification", "description_requires_booker_email_verification": "To ensure booker's email verification before scheduling events", "requires_confirmation_mandatory": "Text messages can only be sent to attendees when event type requires confirmation.", diff --git a/packages/features/ee/teams/components/DeleteBulkTeamMembers.tsx b/packages/features/ee/teams/components/DeleteBulkTeamMembers.tsx new file mode 100644 index 0000000000..11676e2d39 --- /dev/null +++ b/packages/features/ee/teams/components/DeleteBulkTeamMembers.tsx @@ -0,0 +1,57 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button, ConfirmationDialogContent, Dialog, DialogTrigger, showToast } from "@calcom/ui"; + +import type { User } from "./MemberListItem"; + +interface Props { + users: User[]; + onRemove: () => void; + isOrg: boolean; + teamId: number; +} + +export default function DeleteBulkTeamMembers({ users, onRemove, isOrg, teamId }: Props) { + const { t } = useLocale(); + const selectedRows = users; // Get selected rows from table + const utils = trpc.useUtils(); + const deleteMutation = trpc.viewer.teams.removeMember.useMutation({ + async onSuccess() { + await utils.viewer.teams.get.invalidate(); + await utils.viewer.teams.lazyLoadMembers.invalidate(); + await utils.viewer.eventTypes.invalidate(); + await utils.viewer.organizations.listMembers.invalidate(); + await utils.viewer.organizations.getMembers.invalidate(); + showToast("Deleted Users", "success"); + }, + async onError(err) { + showToast(err.message, "error"); + }, + }); + return ( + + + + + { + deleteMutation.mutate({ + teamIds: [teamId], + memberIds: selectedRows.map((user) => user.id), + isOrg, + }); + onRemove(); + }}> +

+ {t("remove_users_from_team_confirm", { + userCount: selectedRows.length, + })} +

+
+
+ ); +} diff --git a/packages/features/ee/teams/components/EditMemberSheet.tsx b/packages/features/ee/teams/components/EditMemberSheet.tsx new file mode 100644 index 0000000000..583f53ec6c --- /dev/null +++ b/packages/features/ee/teams/components/EditMemberSheet.tsx @@ -0,0 +1,249 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import type { Dispatch } from "react"; +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { shallow } from "zustand/shallow"; + +import { DisplayInfo } from "@calcom/features/users/components/UserTable/EditSheet/DisplayInfo"; +import { SheetFooterControls } from "@calcom/features/users/components/UserTable/EditSheet/SheetFooterControls"; +import { useEditMode } from "@calcom/features/users/components/UserTable/EditSheet/store"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { AppCategories } from "@calcom/prisma/enums"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc/react"; +import { + Avatar, + Icon, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetBody, + Skeleton, + Tooltip, + ToggleGroup, + Form, + showToast, + Loader, +} from "@calcom/ui"; + +import { updateRoleInCache } from "./MemberChangeRoleModal"; +import type { Action, State, User } from "./MemberListItem"; + +const formSchema = z.object({ + role: z.enum([MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER]), +}); + +type FormSchema = z.infer; + +type ConnectedAppsType = { + name: string | null; + logo: string | null; + externalId: string | null; + app: { slug: string; categories: AppCategories[] } | null; +}; + +export function EditMemberSheet({ + state, + dispatch, + currentMember, + teamId, +}: { + state: State; + dispatch: Dispatch; + currentMember: MembershipRole; + teamId: number; +}) { + const { t } = useLocale(); + const { user } = state.editSheet; + const selectedUser = user as User; + const [editMode, setEditMode, setMutationLoading] = useEditMode( + (state) => [state.editMode, state.setEditMode, state.setMutationLoading], + shallow + ); + const [role, setRole] = useState(selectedUser.role); + const name = + selectedUser.name || + (() => { + const emailName = selectedUser.email.split("@")[0] as string; + return emailName.charAt(0).toUpperCase() + emailName.slice(1); + })(); + + const bookerUrl = selectedUser.bookerUrl; + const utils = trpc.useUtils(); + const bookerUrlWithoutProtocol = bookerUrl.replace(/^https?:\/\//, ""); + const bookingLink = !!selectedUser.username ? `${bookerUrlWithoutProtocol}/${selectedUser.username}` : ""; + + const options = useMemo(() => { + return [ + { + label: t("member"), + value: MembershipRole.MEMBER, + }, + { + label: t("admin"), + value: MembershipRole.ADMIN, + }, + { + label: t("owner"), + value: MembershipRole.OWNER, + }, + ].filter(({ value }) => value !== MembershipRole.OWNER || currentMember === MembershipRole.OWNER); + }, [t, currentMember]); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + role: selectedUser.role, + }, + }); + + const { data: getUserConnectedApps, isPending } = trpc.viewer.teams.getUserConnectedApps.useQuery({ + userIds: [selectedUser.id], + teamId, + }); + + const connectedApps = getUserConnectedApps?.[selectedUser.id]; + + const changeRoleMutation = trpc.viewer.teams.changeMemberRole.useMutation({ + onMutate: async ({ teamId, memberId, role }) => { + await utils.viewer.teams.lazyLoadMembers.cancel(); + const previousValue = utils.viewer.teams.lazyLoadMembers.getInfiniteData({ + limit: 10, + teamId: teamId, + searchTerm: undefined, + }); + + if (previousValue) { + updateRoleInCache({ utils, teamId, memberId, role, searchTerm: undefined }); + } + + return { previousValue }; + }, + onSuccess: async (_data, { role }) => { + setRole(role); + setMutationLoading(false); + await utils.viewer.teams.get.invalidate(); + await utils.viewer.teams.lazyLoadMembers.invalidate(); + await utils.viewer.organizations.listMembers.invalidate(); + showToast(t("profile_updated_successfully"), "success"); + setEditMode(false); + }, + async onError(err) { + showToast(err.message, "error"); + setMutationLoading(false); + }, + }); + + function changeRole(values: FormSchema) { + setMutationLoading(true); + changeRoleMutation.mutate({ + teamId: teamId, + memberId: user?.id as number, + role: values.role, + }); + } + + const appList = (connectedApps || []).map(({ logo, name, externalId }) => { + return logo ? ( + externalId ? ( +
+ + {`${name} + +
+ ) : ( +
+ {`${name} +
+ ) + ) : null; + }); + + return ( + { + setEditMode(false); + dispatch({ type: "CLOSE_MODAL" }); + }}> + + {!isPending ? ( +
+ +
+
+
+ +
+ +

+ {name || "Nameless User"} +

+
+ +

+ {selectedUser.bio ? selectedUser?.bio : t("user_has_no_bio")} +

+
+
+ + +
+

{t("profile")}

+ + + {!editMode ? ( + + ) : ( +
+
+ + +
+
+ { + form.setValue("role", value); + }} + /> +
+
+ )} +
+
+ + +
+
+ {!connectedApps ? ( +
{t("user_has_no_app_installed")}
+ ) : ( +
{appList}
+ )} +
+
+
+
+ + + + + ) : ( + + )} + + + ); +} diff --git a/packages/features/ee/teams/components/MemberChangeRoleModal.tsx b/packages/features/ee/teams/components/MemberChangeRoleModal.tsx index 3f6379f5d9..45198f70d2 100644 --- a/packages/features/ee/teams/components/MemberChangeRoleModal.tsx +++ b/packages/features/ee/teams/components/MemberChangeRoleModal.tsx @@ -11,7 +11,7 @@ type MembershipRoleOption = { value: MembershipRole; }; -const updateRoleInCache = ({ +export const updateRoleInCache = ({ utils, teamId, searchTerm, diff --git a/packages/features/ee/teams/components/MemberListItem.tsx b/packages/features/ee/teams/components/MemberListItem.tsx index d4f02c6b76..1e6e0cb022 100644 --- a/packages/features/ee/teams/components/MemberListItem.tsx +++ b/packages/features/ee/teams/components/MemberListItem.tsx @@ -1,12 +1,18 @@ +import { keepPreviousData } from "@tanstack/react-query"; +import type { ColumnDef } from "@tanstack/react-table"; import classNames from "classnames"; import { signIn } from "next-auth/react"; -import { useState } from "react"; +import { useSession } from "next-auth/react"; +import { useMemo, useRef, useReducer, useState, useEffect, useCallback } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import { useDebounce } from "@calcom/lib/hooks/useDebounce"; 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 { trpc } from "@calcom/trpc/react"; -import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; +import { Avatar, Badge, Checkbox, DataTable } from "@calcom/ui"; import { Button, ButtonGroup, @@ -23,77 +29,143 @@ import { DropdownMenuTrigger, showToast, Tooltip, - UserAvatar, } from "@calcom/ui"; -import type { ConnectedAppsType } from "../pages/team-members-view"; -import MemberChangeRoleModal from "./MemberChangeRoleModal"; +import DeleteBulkTeamMembers from "./DeleteBulkTeamMembers"; +import { EditMemberSheet } from "./EditMemberSheet"; import TeamAvailabilityModal from "./TeamAvailabilityModal"; -import TeamPill, { TeamRole } from "./TeamPill"; interface Props { team: NonNullable; - member: RouterOutputs["viewer"]["teams"]["lazyLoadMembers"]["members"][number]; isOrgAdminOrOwner: boolean | undefined; - searchTerm: string; - connectedApps: ConnectedAppsType[]; + setShowMemberInvitationModal: Dispatch>; } -/** TODO: Migrate the one in apps/web to tRPC package */ -const useCurrentUserId = () => { - const query = useMeQuery(); - const user = query.data; - return user?.id; -}; +export type User = RouterOutputs["viewer"]["teams"]["lazyLoadMembers"]["members"][number]; const checkIsOrg = (team: Props["team"]) => { return team.isOrganization; }; -const removeMemberFromCache = ({ - utils, - memberId, - teamId, - searchTerm, -}: { - utils: ReturnType; - memberId: number; - teamId: number; - searchTerm: string; -}) => { - utils.viewer.teams.lazyLoadMembers.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), - })), - }; - } - ); +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"; + payload: Payload; + } + | { + type: "CLOSE_MODAL"; + }; + +const initialState: State = { + deleteMember: { + showModal: false, + }, + impersonateMember: { + showModal: false, + }, + editSheet: { + showModal: false, + }, + teamAvailability: { + showModal: false, + }, +}; + +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; + } +} + export default function MemberListItem(props: Props) { const { t, i18n } = useLocale(); - + const { data: session } = useSession(); const utils = trpc.useUtils(); - const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false); - const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false); - const [showImpersonateModal, setShowImpersonateModal] = useState(false); - const [showDeleteModal, setShowDeleteModal] = useState(false); + const [state, dispatch] = useReducer(reducer, initialState); + const tableContainerRef = useRef(null); + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + + const { data, isPending, fetchNextPage, isFetching } = trpc.viewer.teams.lazyLoadMembers.useInfiniteQuery( + { + limit: 10, + searchTerm: debouncedSearchTerm, + teamId: props.team.id, + }, + { + enabled: !!props.team.id, + getNextPageParam: (lastPage) => lastPage.nextCursor, + placeholderData: keepPreviousData, + refetchOnWindowFocus: true, + refetchOnMount: true, + staleTime: 0, + } + ); + + const removeMemberFromCache = ({ + utils, + memberId, + teamId, + searchTerm, + }: { + utils: ReturnType; + memberId: number; + teamId: number; + searchTerm: string; + }) => { + utils.viewer.teams.lazyLoadMembers.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 }) => { @@ -101,15 +173,15 @@ export default function MemberListItem(props: Props) { const previousValue = utils.viewer.teams.lazyLoadMembers.getInfiniteData({ limit: 10, teamId: teamIds[0], - searchTerm: props.searchTerm, + searchTerm: searchTerm, }); if (previousValue) { removeMemberFromCache({ utils, - memberId: props.member.id, + memberId: state.deleteMember.user?.id as number, teamId: teamIds[0], - searchTerm: props.searchTerm, + searchTerm: searchTerm, }); } return { previousValue }; @@ -119,7 +191,6 @@ export default function MemberListItem(props: Props) { await utils.viewer.eventTypes.invalidate(); await utils.viewer.organizations.listMembers.invalidate(); await utils.viewer.organizations.getMembers.invalidate(); - showToast(t("success"), "success"); }, async onError(err) { @@ -142,242 +213,413 @@ export default function MemberListItem(props: Props) { // return owners.length; // }; - const currentUserId = useCurrentUserId(); - - const name = - props.member.name || - (() => { - const emailName = props.member.email.split("@")[0]; - return emailName.charAt(0).toUpperCase() + emailName.slice(1); - })(); + const isAdminOrOwner = + props.team.membership.role === MembershipRole.OWNER || + props.team.membership.role === MembershipRole.ADMIN; const removeMember = () => removeMemberMutation.mutate({ teamIds: [props.team?.id], - memberIds: [props.member.id], + memberIds: [state.deleteMember.user?.id as number], isOrg: checkIsOrg(props.team), }); - const editMode = - (props.team.membership?.role === MembershipRole.OWNER && - (props.member.role !== MembershipRole.OWNER || - // ownersInTeam() > 1 || - props.member.id !== currentUserId)) || - (props.team.membership?.role === MembershipRole.ADMIN && props.member.role !== MembershipRole.OWNER) || - props.isOrgAdminOrOwner; - const impersonationMode = - editMode && - !props.member.disableImpersonation && - props.member.accepted && - process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true"; - const resendInvitation = editMode && !props.member.accepted; + const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; - const bookerUrl = props.member.bookerUrl; - const bookerUrlWithoutProtocol = bookerUrl.replace(/^https?:\/\//, ""); - const bookingLink = !!props.member.username && `${bookerUrlWithoutProtocol}/${props.member.username}`; - const isAdmin = props.team && ["ADMIN", "OWNER"].includes(props.team.membership?.role); - const appList = props.connectedApps?.map(({ logo, name, externalId }) => { - return logo ? ( - externalId ? ( -
- - {`${name} - -
- ) : ( -
- {`${name} -
- ) - ) : null; - }); - - return ( -
  • -
    -
    -
    - -
    -
    - - {name} - - {!props.member.accepted && ( - - )} - {isAdmin && props.member.accepted && appList} - {props.member.role && } -
    -
    - - {props.member.email} - - {bookingLink && ( - <> - • - - {bookingLink} - - - )} + const memorisedColumns = useMemo(() => { + const cols: ColumnDef[] = [ + { + 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, accepted, name } = row.original; + const memberName = + name || + (() => { + const emailName = email.split("@")[0]; + return emailName.charAt(0).toUpperCase() + emailName.slice(1); + })(); + return ( +
    + +
    +
    + {memberName} +
    +
    + {email} +
    -
    -
    - {props.team.membership?.accepted && ( -
    -
    + ); + }, + 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: "actions", + 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 && ( +
    +
    +
    )} - -
    - - -
    -
    - )} -
    + + ); + }, + }, + ]; - {editMode && ( - setShowDeleteModal((prev) => !prev)}> + return cols; + }, [props.isOrgAdminOrOwner, dispatch, totalDBRowCount, session?.user.id]); + + const flatData = useMemo(() => data?.pages?.flatMap((page) => page.members) ?? [], [data]) as User[]; + const totalFetched = flatData.length; + + 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 ( +
    + setSearchTerm(value)} + selectionOptions={[ + { + type: "render", + render: (table) => ( + row.original)} + onRemove={() => table.toggleAllPageRowsSelected(false)} + isOrg={checkIsOrg(props.team)} + teamId={props.team.id} + /> + ), + }, + ]} + tableContainerRef={tableContainerRef} + onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)} + tableCTA={ + isAdminOrOwner || props.isOrgAdminOrOwner ? ( + + ) : ( + <> + ) + } + columns={memorisedColumns} + data={flatData} + isPending={isPending} + filterableItems={[ + { + tableAccessor: "role", + title: "Role", + options: [ + { label: "Owner", value: "OWNER" }, + { label: "Admin", value: "ADMIN" }, + { label: "Member", value: "MEMBER" }, + { label: "Pending", value: "PENDING" }, + ], + }, + ]} + /> + + {state.deleteMember.showModal && ( + + !open && + dispatch({ + type: "CLOSE_MODAL", + }) + }> )} - {showImpersonateModal && props.member.username && ( - setShowImpersonateModal(false)}> + {state.impersonateMember.showModal && state.impersonateMember.user?.username && ( + + dispatch({ + type: "CLOSE_MODAL", + }) + }>
    { e.preventDefault(); await signIn("impersonation-auth", { - username: props.member.email, + username: state.impersonateMember.user?.email, teamId: props.team.id, }); - setShowImpersonateModal(false); + dispatch({ + type: "CLOSE_MODAL", + }); }}> {t("cancel")} @@ -410,28 +660,27 @@ export default function MemberListItem(props: Props) {
    )} - - {showChangeMemberRoleModal && ( - setShowChangeMemberRoleModal(false)} - /> - )} - {showTeamAvailabilityModal && ( - setShowTeamAvailabilityModal(false)}> + {state.teamAvailability.showModal && ( + { + dispatch({ + type: "CLOSE_MODAL", + }); + }}> - - - - + )} -
  • + {state.editSheet.showModal && ( + + )} +
    ); } diff --git a/packages/features/ee/teams/pages/team-members-view.tsx b/packages/features/ee/teams/pages/team-members-view.tsx index c24ba1806f..3f05eda1f3 100644 --- a/packages/features/ee/teams/pages/team-members-view.tsx +++ b/packages/features/ee/teams/pages/team-members-view.tsx @@ -1,21 +1,14 @@ "use client"; -import { keepPreviousData } from "@tanstack/react-query"; import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; -import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; -import { useDebounce } from "@calcom/lib/hooks/useDebounce"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import { MembershipRole } from "@calcom/prisma/enums"; -import type { AppCategories } from "@calcom/prisma/enums"; -import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; -import { Button, Meta, TextField } from "@calcom/ui"; - -import { useInViewObserver } from "@lib/hooks/useInViewObserver"; +import { Meta } from "@calcom/ui"; import { getLayout } from "../../../settings/layouts/SettingsLayout"; import DisableTeamImpersonation from "../components/DisableTeamImpersonation"; @@ -25,128 +18,17 @@ import { MemberInvitationModalWithoutMembers } from "../components/MemberInvitat import MemberListItem from "../components/MemberListItem"; import TeamInviteList from "../components/TeamInviteList"; -type Team = RouterOutputs["viewer"]["teams"]["getMinimal"]; - -interface MembersListProps { - team: Team; - isOrgAdminOrOwner: boolean | undefined; -} - -export type ConnectedAppsType = { - name: string | null; - logo: string | null; - externalId: string | null; - app: { slug: string; categories: AppCategories[] } | null; -}; - -function MembersList(props: MembersListProps) { - const { team, isOrgAdminOrOwner } = props; - const { t } = useLocale(); - const [searchTerm, setSearchTerm] = useState(""); - const debouncedSearchTerm = useDebounce(searchTerm, 500); - const [connectedApps, setConnectedApps] = useState>({}); - const [userIds, setUserIds] = useState([]); - - const { data: getUserConnectedApps } = trpc.viewer.teams.getUserConnectedApps.useQuery( - { userIds, teamId: team.id }, - { enabled: !!userIds.length } - ); - - const { data, isFetching, status, fetchNextPage, isFetchingNextPage, hasNextPage } = - trpc.viewer.teams.lazyLoadMembers.useInfiniteQuery( - { - limit: 10, - searchTerm: debouncedSearchTerm, - teamId: team.id, - }, - { - enabled: !!team?.id, - getNextPageParam: (lastPage) => lastPage.nextCursor, - placeholderData: keepPreviousData, - refetchOnWindowFocus: true, - refetchOnMount: true, - staleTime: 0, - } - ); - - // To defer fetching Connected Apps - useEffect(() => { - if (data?.pages) { - const userIds = data.pages[data.pages.length - 1].members.map((member) => member.id); - setUserIds(userIds); - } - }, [data]); - - useEffect(() => { - if (getUserConnectedApps) { - setConnectedApps((prev) => ({ ...prev, ...getUserConnectedApps })); - } - }, [getUserConnectedApps]); - - const buttonInView = useInViewObserver(() => { - if (!isFetching && hasNextPage && status === "success") { - fetchNextPage(); - } - }, null); - - return ( -
    - setSearchTerm(e.target.value)} - value={searchTerm} - placeholder={`${t("search")}...`} - /> - {data?.pages[0]?.members?.length && team ? ( -
      - {data.pages?.map((page) => { - return page.members.map((member) => { - return ( - - ); - }); - })} -
    - ) : null} -
    - -
    -
    - ); -} - const MembersView = () => { - const searchParams = useCompatSearchParams(); const { t } = useLocale(); - + const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false); + const [showInviteLinkSettingsModal, setInviteLinkSettingsModal] = useState(false); const router = useRouter(); const session = useSession(); const org = session?.data?.user.org; - const params = useParamsWithFallback(); const teamId = Number(params.id); - const showDialog = searchParams?.get("inviteModal") === "true"; - const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(showDialog); - const [showInviteLinkSettingsModal, setInviteLinkSettingsModal] = useState(false); - const { data: team, isPending: isTeamsLoading, @@ -157,7 +39,6 @@ const MembersView = () => { enabled: !!teamId, } ); - useEffect( function refactorMeWithoutEffect() { if (teamError) { @@ -182,25 +63,7 @@ const MembersView = () => { return ( <> - setShowMemberInvitationModal(true)} - data-testid="new-member-button"> - {t("add")} - - ) : ( - <> - ) - } - /> + } /> {!isPending && ( <>
    @@ -222,7 +85,39 @@ const MembersView = () => { )} - {team && team.id && session.data && ( + {((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && team && ( + <> + + + )} + {showMemberInvitationModal && team && team.id && ( + setInviteLinkSettingsModal(true)} + /> + )} + + {showInviteLinkSettingsModal && team?.inviteToken && team.id && ( + { + setInviteLinkSettingsModal(false); + setShowMemberInvitationModal(true); + }} + /> + )} + + {team && session.data && ( { disabled={isInviteOpen} /> )} - - {((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && team && ( - <> - - - )}
    - {showMemberInvitationModal && team && team.id && ( - setInviteLinkSettingsModal(true)} - /> - )} - - {showInviteLinkSettingsModal && team?.inviteToken && team.id && ( - { - setInviteLinkSettingsModal(false); - setShowMemberInvitationModal(true); - }} - /> - )} )} diff --git a/packages/features/users/components/UserTable/EditSheet/SheetFooterControls.tsx b/packages/features/users/components/UserTable/EditSheet/SheetFooterControls.tsx index bc899b074a..9a5973ba0f 100644 --- a/packages/features/users/components/UserTable/EditSheet/SheetFooterControls.tsx +++ b/packages/features/users/components/UserTable/EditSheet/SheetFooterControls.tsx @@ -13,13 +13,14 @@ function EditModeFooter() { - @@ -39,6 +40,7 @@ function MoreInfoFooter() {