feat: replace team list view with org list view UI and fix team view user availability (#15344)
* feat: replace team list view with org list view UI and fix team view user availability * update * update2 * some changes * Remove old team availability modal - fix flex direction * fix * fix type error * update * update * fix type error * update * upfate edit sheet * final update * fix flaky e2e * fix e2e * typo * profile update flow * fix type error and refactor * small update * NIT * update * chore: fix types * update --------- Co-authored-by: sean-brydon <sean@cal.com> Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
11 files changed
+927
-481
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button StartIcon="ban">{t("Delete")}</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_users_from_team")}
|
||||
confirmBtnText={t("remove")}
|
||||
isPending={deleteMutation.isPending}
|
||||
onConfirm={() => {
|
||||
deleteMutation.mutate({
|
||||
teamIds: [teamId],
|
||||
memberIds: selectedRows.map((user) => user.id),
|
||||
isOrg,
|
||||
});
|
||||
onRemove();
|
||||
}}>
|
||||
<p className="mt-5">
|
||||
{t("remove_users_from_team_confirm", {
|
||||
userCount: selectedRows.length,
|
||||
})}
|
||||
</p>
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof formSchema>;
|
||||
|
||||
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<Action>;
|
||||
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 ? (
|
||||
<div className="ltr:mr-2 rtl:ml-2 ">
|
||||
<Tooltip content={externalId}>
|
||||
<img className="h-5 w-5" src={logo} alt={`${name} logo`} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ltr:mr-2 rtl:ml-2">
|
||||
<img className="h-5 w-5" src={logo} alt={`${name} logo`} />
|
||||
</div>
|
||||
)
|
||||
) : null;
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={true}
|
||||
onOpenChange={() => {
|
||||
setEditMode(false);
|
||||
dispatch({ type: "CLOSE_MODAL" });
|
||||
}}>
|
||||
<SheetContent className="bg-muted">
|
||||
{!isPending ? (
|
||||
<Form form={form} handleSubmit={changeRole} className="flex h-full flex-col">
|
||||
<SheetHeader showCloseButton={false} className="w-full">
|
||||
<div className="border-sublte bg-default w-full rounded-xl border p-4">
|
||||
<div
|
||||
className="block w-full rounded-lg ring-1 ring-[#0000000F]"
|
||||
style={{
|
||||
background: "linear-gradient(to top right, var(--cal-bg-emphasis), var(--cal-bg))",
|
||||
height: "110px",
|
||||
}}
|
||||
/>
|
||||
<div className="bg-default ml-3 w-fit translate-y-[-50%] rounded-full p-1 ring-1 ring-[#0000000F]">
|
||||
<Avatar asChild size="lg" alt={`${name} avatar`} imageSrc={selectedUser.avatarUrl} />
|
||||
</div>
|
||||
<Skeleton as="p" waitForTranslation={false}>
|
||||
<h2 className="text-emphasis font-sans text-2xl font-semibold">
|
||||
{name || "Nameless User"}
|
||||
</h2>
|
||||
</Skeleton>
|
||||
<Skeleton as="p" waitForTranslation={false}>
|
||||
<p className="text-subtle max-h-[3em] overflow-hidden text-ellipsis text-sm font-normal">
|
||||
{selectedUser.bio ? selectedUser?.bio : t("user_has_no_bio")}
|
||||
</p>
|
||||
</Skeleton>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetBody className="flex flex-col space-y-4 p-4">
|
||||
<div className="mb-4 flex flex-col space-y-4">
|
||||
<h3 className="text-emphasis mb-1 text-base font-semibold">{t("profile")}</h3>
|
||||
<DisplayInfo label="Cal" value={bookingLink} icon="external-link" />
|
||||
<DisplayInfo label={t("email")} value={selectedUser.email} icon="at-sign" />
|
||||
{!editMode ? (
|
||||
<DisplayInfo label={t("role")} value={[role]} icon="fingerprint" />
|
||||
) : (
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex w-[110px] items-center gap-2">
|
||||
<Icon className="h-4 w-4" name="fingerprint" />
|
||||
<label className="text-sm font-medium">{t("role")}</label>
|
||||
</div>
|
||||
<div className="flex flex-1">
|
||||
<ToggleGroup
|
||||
isFullWidth
|
||||
defaultValue={role}
|
||||
value={form.watch("role")}
|
||||
options={options}
|
||||
onValueChange={(value: FormSchema["role"]) => {
|
||||
form.setValue("role", value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex w-[110px] items-center gap-2">
|
||||
<Icon className="text-subtle h-4 w-4" name="grid-3x3" />
|
||||
<label className="text-subtle text-sm font-medium">{t("apps")}</label>
|
||||
</div>
|
||||
<div className="flex flex-1">
|
||||
{!connectedApps ? (
|
||||
<div>{t("user_has_no_app_installed")}</div>
|
||||
) : (
|
||||
<div className="flex">{appList}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetBody>
|
||||
<SheetFooter className="mt-auto">
|
||||
<SheetFooterControls />
|
||||
</SheetFooter>
|
||||
</Form>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ type MembershipRoleOption = {
|
||||
value: MembershipRole;
|
||||
};
|
||||
|
||||
const updateRoleInCache = ({
|
||||
export const updateRoleInCache = ({
|
||||
utils,
|
||||
teamId,
|
||||
searchTerm,
|
||||
|
||||
@@ -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<RouterOutputs["viewer"]["teams"]["getMinimal"]>;
|
||||
member: RouterOutputs["viewer"]["teams"]["lazyLoadMembers"]["members"][number];
|
||||
isOrgAdminOrOwner: boolean | undefined;
|
||||
searchTerm: string;
|
||||
connectedApps: ConnectedAppsType[];
|
||||
setShowMemberInvitationModal: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
/** 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<typeof trpc.useUtils>;
|
||||
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<HTMLDivElement>(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<typeof trpc.useUtils>;
|
||||
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 ? (
|
||||
<div className="ltr:mr-2 rtl:ml-2 ">
|
||||
<Tooltip content={externalId}>
|
||||
<img className="h-5 w-5" src={logo} alt={`${name} logo`} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ltr:mr-2 rtl:ml-2">
|
||||
<img className="h-5 w-5" src={logo} alt={`${name} logo`} />
|
||||
</div>
|
||||
)
|
||||
) : null;
|
||||
});
|
||||
|
||||
return (
|
||||
<li className="divide-subtle divide-y px-5">
|
||||
<div className="my-4 flex justify-between">
|
||||
<div className="flex w-full flex-col justify-between truncate sm:flex-row">
|
||||
<div className="flex">
|
||||
<UserAvatar
|
||||
noOrganizationIndicator
|
||||
size="sm"
|
||||
user={props.member}
|
||||
className="h-10 w-10 rounded-full"
|
||||
/>
|
||||
<div className="ms-3 inline-block">
|
||||
<div className="mb-1 flex" data-testid={`member-${props.member.username}`}>
|
||||
<span data-testid="member-name" className="text-default mr-2 text-sm font-bold leading-4">
|
||||
{name}
|
||||
</span>
|
||||
{!props.member.accepted && (
|
||||
<TeamPill data-testid="member-pending" color="orange" text={t("pending")} />
|
||||
)}
|
||||
{isAdmin && props.member.accepted && appList}
|
||||
{props.member.role && <TeamRole data-testid="member-role" role={props.member.role} />}
|
||||
</div>
|
||||
<div className="text-default flex items-center">
|
||||
<span
|
||||
className=" block text-sm"
|
||||
data-testid={
|
||||
props.member.accepted
|
||||
? "member-email"
|
||||
: `email-${props.member.email.replace("@", "")}-pending`
|
||||
}
|
||||
data-email={props.member.email}>
|
||||
{props.member.email}
|
||||
</span>
|
||||
{bookingLink && (
|
||||
<>
|
||||
<span className="text-default mx-2 block">•</span>
|
||||
<a
|
||||
target="_blank"
|
||||
href={`${bookerUrl}/${props.member.username}`}
|
||||
className="text-default block text-sm">
|
||||
{bookingLink}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
const memorisedColumns = useMemo(() => {
|
||||
const cols: ColumnDef<User>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => 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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
size="sm"
|
||||
alt={username || email}
|
||||
imageSrc={getUserAvatarUrl({
|
||||
avatarUrl,
|
||||
})}
|
||||
/>
|
||||
<div data-testid={`member-${username}`}>
|
||||
<div data-testid="member-name" className="text-emphasis text-sm font-medium leading-none">
|
||||
{memberName}
|
||||
</div>
|
||||
<div
|
||||
data-testid={accepted ? "member-email" : `email-${email.replace("@", "")}-pending`}
|
||||
className="text-subtle mt-1 text-sm leading-none">
|
||||
{email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{props.team.membership?.accepted && (
|
||||
<div className="flex items-center justify-center">
|
||||
<ButtonGroup combined containerProps={{ className: "border-default hidden md:flex" }}>
|
||||
{/* TODO: bring availability back. right now its ugly and broken
|
||||
<Tooltip
|
||||
content={
|
||||
props.member.accepted
|
||||
? t("team_view_user_availability")
|
||||
: t("team_view_user_availability_disabled")
|
||||
}>
|
||||
<Button
|
||||
disabled={!props.member.accepted}
|
||||
onClick={() => (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="clock"
|
||||
/>
|
||||
</Tooltip> */}
|
||||
{!!props.member.accepted && (
|
||||
<Tooltip content={t("view_public_page")}>
|
||||
<Button
|
||||
target="_blank"
|
||||
href={`${bookerUrl}/${props.member.username}`}
|
||||
color="secondary"
|
||||
className={classNames(!editMode ? "rounded-r-md" : "")}
|
||||
variant="icon"
|
||||
StartIcon="external-link"
|
||||
disabled={!props.member.accepted}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
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, accepted } = row.original;
|
||||
return (
|
||||
<div className="flex h-full flex-wrap items-center gap-2">
|
||||
{!accepted && (
|
||||
<Badge
|
||||
data-testid="member-pending"
|
||||
variant="orange"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
table.getColumn("role")?.setFilterValue(["PENDING"]);
|
||||
}}>
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
{editMode && (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="radix-state-open:rounded-r-md"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="ellipsis"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() => setShowChangeMemberRoleModal(true)}
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{impersonationMode && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Badge
|
||||
data-testid="member-role"
|
||||
variant={role === "MEMBER" ? "gray" : "blue"}
|
||||
onClick={() => {
|
||||
table.getColumn("role")?.setFilterValue([role]);
|
||||
}}>
|
||||
{role}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
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 && (
|
||||
<div className="flex items-center justify-end">
|
||||
<ButtonGroup combined containerProps={{ className: "border-default hidden md:flex" }}>
|
||||
{/* TODO: bring availability back. right now its ugly and broken
|
||||
<Tooltip
|
||||
content={
|
||||
user.accepted
|
||||
? t("team_view_user_availability")
|
||||
: t("team_view_user_availability_disabled")
|
||||
}>
|
||||
<Button
|
||||
disabled={!user.accepted}
|
||||
onClick={() =>
|
||||
user.accepted
|
||||
? dispatch({
|
||||
type: "TEAM_AVAILABILITY",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
: null
|
||||
}
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="clock"
|
||||
/>
|
||||
</Tooltip> */}
|
||||
{!!user.accepted && (
|
||||
<Tooltip content={t("view_public_page")}>
|
||||
<Button
|
||||
target="_blank"
|
||||
href={`${user.bookerUrl}/${user.username}`}
|
||||
color="secondary"
|
||||
className={classNames(!editMode ? "rounded-r-md" : "")}
|
||||
variant="icon"
|
||||
StartIcon="external-link"
|
||||
disabled={!user.accepted}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{editMode && (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="radix-state-open:rounded-r-md"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="ellipsis"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "EDIT_USER_SHEET",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{impersonationMode && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_IMPERSONATE_ID",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon="lock">
|
||||
{t("impersonate")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{resendInvitation && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resendInvitationMutation.mutate({
|
||||
teamId: props.team?.id,
|
||||
email: user.email,
|
||||
language: i18n.language,
|
||||
});
|
||||
}}
|
||||
StartIcon="send">
|
||||
{t("resend_invitation")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_DELETE_ID",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
color="destructive"
|
||||
StartIcon="user-x">
|
||||
{t("remove")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
<div className="flex md:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="icon" color="minimal" StartIcon="ellipsis" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
disabled={!user.accepted}
|
||||
href={!user.accepted ? undefined : `/${user.username}`}
|
||||
target="_blank"
|
||||
type="button"
|
||||
onClick={() => setShowImpersonateModal(true)}
|
||||
StartIcon="lock">
|
||||
{t("impersonate")}
|
||||
StartIcon="external-link">
|
||||
{t("view_public_page")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{resendInvitation && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resendInvitationMutation.mutate({
|
||||
teamId: props.team?.id,
|
||||
email: props.member.email,
|
||||
language: i18n.language,
|
||||
});
|
||||
}}
|
||||
StartIcon="send">
|
||||
{t("resend_invitation")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
color="destructive"
|
||||
StartIcon="user-x">
|
||||
{t("remove")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
{editMode && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "EDIT_USER_SHEET",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_DELETE_ID",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon="user-x">
|
||||
{t("remove")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
<div className="flex md:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="icon" color="minimal" StartIcon="ellipsis" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
disabled={!props.member.accepted}
|
||||
href={!props.member.accepted ? undefined : `/${props.member.username}`}
|
||||
target="_blank"
|
||||
type="button"
|
||||
StartIcon="external-link">
|
||||
{t("view_public_page")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{editMode && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() => setShowChangeMemberRoleModal(true)}
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
StartIcon="user-x">
|
||||
{t("remove")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
{editMode && (
|
||||
<Dialog open={showDeleteModal} onOpenChange={() => 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 (
|
||||
<div className="mb-6">
|
||||
<DataTable
|
||||
data-testid="team-member-list-container"
|
||||
onSearch={(value) => setSearchTerm(value)}
|
||||
selectionOptions={[
|
||||
{
|
||||
type: "render",
|
||||
render: (table) => (
|
||||
<DeleteBulkTeamMembers
|
||||
users={table.getSelectedRowModel().flatRows.map((row) => 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 ? (
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
StartIcon="plus"
|
||||
size="sm"
|
||||
className="rounded-md"
|
||||
onClick={() => props.setShowMemberInvitationModal(true)}
|
||||
data-testid="new-member-button">
|
||||
{t("add")}
|
||||
</Button>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
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 && (
|
||||
<Dialog
|
||||
open={true}
|
||||
onOpenChange={(open) =>
|
||||
!open &&
|
||||
dispatch({
|
||||
type: "CLOSE_MODAL",
|
||||
})
|
||||
}>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_member")}
|
||||
@@ -388,17 +630,25 @@ export default function MemberListItem(props: Props) {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{showImpersonateModal && props.member.username && (
|
||||
<Dialog open={showImpersonateModal} onOpenChange={() => setShowImpersonateModal(false)}>
|
||||
{state.impersonateMember.showModal && state.impersonateMember.user?.username && (
|
||||
<Dialog
|
||||
open={true}
|
||||
onOpenChange={() =>
|
||||
dispatch({
|
||||
type: "CLOSE_MODAL",
|
||||
})
|
||||
}>
|
||||
<DialogContent type="creation" title={t("impersonate")} description={t("impersonation_user_tip")}>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
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",
|
||||
});
|
||||
}}>
|
||||
<DialogFooter showDivider className="mt-8">
|
||||
<DialogClose color="secondary">{t("cancel")}</DialogClose>
|
||||
@@ -410,28 +660,27 @@ export default function MemberListItem(props: Props) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{showChangeMemberRoleModal && (
|
||||
<MemberChangeRoleModal
|
||||
isOpen={showChangeMemberRoleModal}
|
||||
currentMember={props.team.membership.role}
|
||||
teamId={props.team?.id}
|
||||
memberId={props.member.id}
|
||||
searchTerm={props.searchTerm}
|
||||
initialRole={props.member.role as MembershipRole}
|
||||
onExit={() => setShowChangeMemberRoleModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showTeamAvailabilityModal && (
|
||||
<Dialog open={showTeamAvailabilityModal} onOpenChange={() => setShowTeamAvailabilityModal(false)}>
|
||||
{state.teamAvailability.showModal && (
|
||||
<Dialog
|
||||
open={true}
|
||||
onOpenChange={() => {
|
||||
dispatch({
|
||||
type: "CLOSE_MODAL",
|
||||
});
|
||||
}}>
|
||||
<DialogContent type="creation" size="md">
|
||||
<TeamAvailabilityModal team={props.team} member={props.member} />
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
|
||||
</DialogFooter>
|
||||
<TeamAvailabilityModal team={props.team} member={state.teamAvailability.user} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</li>
|
||||
{state.editSheet.showModal && (
|
||||
<EditMemberSheet
|
||||
dispatch={dispatch}
|
||||
state={state}
|
||||
currentMember={props.team.membership.role}
|
||||
teamId={props.team.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<number, ConnectedAppsType[]>>({});
|
||||
const [userIds, setUserIds] = useState<number[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<TextField
|
||||
type="search"
|
||||
autoComplete="false"
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
value={searchTerm}
|
||||
placeholder={`${t("search")}...`}
|
||||
/>
|
||||
{data?.pages[0]?.members?.length && team ? (
|
||||
<ul
|
||||
className="divide-subtle border-subtle divide-y rounded-md border "
|
||||
data-testId="team-member-list-container">
|
||||
{data.pages?.map((page) => {
|
||||
return page.members.map((member) => {
|
||||
return (
|
||||
<MemberListItem
|
||||
key={member.id}
|
||||
team={team}
|
||||
member={member}
|
||||
isOrgAdminOrOwner={isOrgAdminOrOwner}
|
||||
searchTerm={debouncedSearchTerm}
|
||||
connectedApps={connectedApps[member.id] ?? []}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={isFetchingNextPage}
|
||||
disabled={!hasNextPage}
|
||||
onClick={() => fetchNextPage()}>
|
||||
{hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Meta
|
||||
title={t("team_members")}
|
||||
description={t("members_team_description")}
|
||||
CTA={
|
||||
isAdmin || isOrgAdminOrOwner ? (
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
StartIcon="plus"
|
||||
className="ml-auto"
|
||||
onClick={() => setShowMemberInvitationModal(true)}
|
||||
data-testid="new-member-button">
|
||||
{t("add")}
|
||||
</Button>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Meta title={t("team_members")} description={t("members_team_description")} CTA={<></>} />
|
||||
{!isPending && (
|
||||
<>
|
||||
<div>
|
||||
@@ -222,7 +85,39 @@ const MembersView = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{team && team.id && session.data && (
|
||||
{((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && team && (
|
||||
<>
|
||||
<MemberListItem
|
||||
team={team}
|
||||
isOrgAdminOrOwner={isOrgAdminOrOwner}
|
||||
setShowMemberInvitationModal={setShowMemberInvitationModal}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showMemberInvitationModal && team && team.id && (
|
||||
<MemberInvitationModalWithoutMembers
|
||||
hideInvitationModal={hideInvitationModal}
|
||||
showMemberInvitationModal={showMemberInvitationModal}
|
||||
teamId={team.id}
|
||||
token={team.inviteToken?.token}
|
||||
onSettingsOpen={() => setInviteLinkSettingsModal(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInviteLinkSettingsModal && team?.inviteToken && team.id && (
|
||||
<InviteLinkSettingsModal
|
||||
isOpen={showInviteLinkSettingsModal}
|
||||
teamId={team.id}
|
||||
token={team.inviteToken.token}
|
||||
expiresInDays={team.inviteToken.expiresInDays || undefined}
|
||||
onExit={() => {
|
||||
setInviteLinkSettingsModal(false);
|
||||
setShowMemberInvitationModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{team && session.data && (
|
||||
<DisableTeamImpersonation
|
||||
teamId={team.id}
|
||||
memberId={session.data.user.id}
|
||||
@@ -238,35 +133,7 @@ const MembersView = () => {
|
||||
disabled={isInviteOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && team && (
|
||||
<>
|
||||
<MembersList team={team} isOrgAdminOrOwner={isOrgAdminOrOwner} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showMemberInvitationModal && team && team.id && (
|
||||
<MemberInvitationModalWithoutMembers
|
||||
hideInvitationModal={hideInvitationModal}
|
||||
showMemberInvitationModal={showMemberInvitationModal}
|
||||
teamId={team.id}
|
||||
token={team.inviteToken?.token}
|
||||
onSettingsOpen={() => setInviteLinkSettingsModal(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInviteLinkSettingsModal && team?.inviteToken && team.id && (
|
||||
<InviteLinkSettingsModal
|
||||
isOpen={showInviteLinkSettingsModal}
|
||||
teamId={team.id}
|
||||
token={team.inviteToken.token}
|
||||
expiresInDays={team.inviteToken.expiresInDays || undefined}
|
||||
onExit={() => {
|
||||
setInviteLinkSettingsModal(false);
|
||||
setShowMemberInvitationModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -13,13 +13,14 @@ function EditModeFooter() {
|
||||
<Button
|
||||
color="secondary"
|
||||
type="button"
|
||||
className="justify-center"
|
||||
onClick={() => {
|
||||
setEditMode(false);
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button type="submit" className="w-fit" form="edit-user-form" loading={isPending}>
|
||||
<Button type="submit" loading={isPending} className="justify-center">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</>
|
||||
@@ -39,6 +40,7 @@ function MoreInfoFooter() {
|
||||
</SheetClose>
|
||||
<Button
|
||||
type="button"
|
||||
className="justify-center"
|
||||
onClick={() => {
|
||||
setEditMode(true);
|
||||
}}
|
||||
|
||||
@@ -4,12 +4,12 @@ interface EditModeState {
|
||||
editMode: boolean;
|
||||
setEditMode: (editMode: boolean) => void;
|
||||
mutationLoading: boolean;
|
||||
setMutationloading: (loading: boolean) => void;
|
||||
setMutationLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useEditMode = create<EditModeState>((set) => ({
|
||||
editMode: false,
|
||||
setEditMode: (editMode) => set({ editMode }),
|
||||
mutationLoading: false,
|
||||
setMutationloading: (loading) => set({ mutationLoading: loading }),
|
||||
setMutationLoading: (loading) => set({ mutationLoading: loading }),
|
||||
}));
|
||||
@@ -28,7 +28,8 @@ export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOp
|
||||
|
||||
const targetMembership = memberships.find((m) => m.userId === input.memberId);
|
||||
const myMembership = memberships.find((m) => m.userId === ctx.user.id);
|
||||
const teamHasMoreThanOneOwner = memberships.some((m) => m.role === MembershipRole.OWNER);
|
||||
const teamOwners = memberships.filter((m) => m.role === MembershipRole.OWNER);
|
||||
const teamHasMoreThanOneOwner = teamOwners.length > 1;
|
||||
|
||||
if (myMembership?.role === MembershipRole.ADMIN && targetMembership?.role === MembershipRole.OWNER) {
|
||||
throw new TRPCError({
|
||||
@@ -37,7 +38,7 @@ export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOp
|
||||
});
|
||||
}
|
||||
|
||||
if (!teamHasMoreThanOneOwner) {
|
||||
if (targetMembership?.role === MembershipRole.OWNER && !teamHasMoreThanOneOwner) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can not change the role of the only owner of a team.",
|
||||
|
||||
@@ -40,6 +40,12 @@ export const lazyLoadMembersHandler = async ({ ctx, input }: LazyLoadMembersHand
|
||||
});
|
||||
}
|
||||
|
||||
const getTotalMembers = await prisma.membership.count({
|
||||
where: {
|
||||
teamId: teamId,
|
||||
},
|
||||
});
|
||||
|
||||
const teamMembers = await prisma.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
@@ -109,7 +115,13 @@ export const lazyLoadMembersHandler = async ({ ctx, input }: LazyLoadMembersHand
|
||||
})
|
||||
);
|
||||
|
||||
return { members: membersWithApps, nextCursor };
|
||||
return {
|
||||
members: membersWithApps,
|
||||
nextCursor,
|
||||
meta: {
|
||||
totalRowCount: getTotalMembers || 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const checkCanAccessMembers = async (ctx: LazyLoadMembersHandlerOptions["ctx"], teamId: number) => {
|
||||
|
||||
Reference in new issue
Block a user