Skip to content

Commit

Permalink
refactor: improve team members page performance (#16155)
Browse files Browse the repository at this point in the history
* fix: simplify workflow page and improve load time

* chore: use new endpoint

* chore: save progress

* refactor: code

* refactor: remove not requried code

* chore: remove schema

* chore: fix typ

* chore: improve

* chore: change name

* chore: remove unused

* chore: remove page

* refactor: teams page

* feat: add auto scroll

* chore: create validate unique invite

* fix: auth check

* fix: optimistic update

* chore

* fix: add loading

* fix: improvements

* chore: remove

* chore

* chore: fix teams page

* fix: team profile page

* fix: appearance page

* fix: sso view

* fix: type err

* feat: defer loading connected Apps

* fix: type err

* fix: type error

* fix: type err

* fix: connectedApps type

* chore: move

* chore: missing export

* feat: add search by name

* fix: display role change

* fix: use setInfiniteData

* chore: save progress

* test: add unit tests for loading members

* fix: test

* chore: update name

* fix: bugs and improvements

* chore: change variable name

* test: add tests for checkCanAccessMembers

* refactor: performance

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
  • Loading branch information
2 people authored and zomars committed Sep 4, 2024
1 parent 3f7cc2e commit 981b017
Show file tree
Hide file tree
Showing 26 changed files with 1,315 additions and 197 deletions.
1 change: 1 addition & 0 deletions apps/web/test/utils/bookingScenario/bookingScenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
name: string;
slug: string;
parentId?: number;
isPrivate?: boolean;
};
}[];
schedules: {
Expand Down
11 changes: 10 additions & 1 deletion packages/features/ee/sso/page/teams-sso-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,16 @@ const SAMLSSO = () => {

const teamId = Number(params.id);

const { data: team, isPending, error } = trpc.viewer.teams.get.useQuery({ teamId });
const {
data: team,
isPending,
error,
} = trpc.viewer.teams.getMinimal.useQuery(
{ teamId },
{
enabled: !!teamId,
}
);

useEffect(() => {
if (!HOSTED_CAL_FEATURES) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ const DisableTeamImpersonation = ({
setAllowImpersonation(_allowImpersonation);
mutation.mutate({ teamId, memberId, disableImpersonation: !_allowImpersonation });
}}
switchContainerClassName="mt-6"
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export default function InviteLinkSettingsModal(props: InvitationLinkSettingsMod
showToast(t("invite_link_deleted"), "success");
trpcContext.viewer.teams.get.invalidate();
trpcContext.viewer.teams.list.invalidate();
trpcContext.viewer.teams.getMinimal.invalidate();
props.onExit();
},
onError: (e) => {
Expand All @@ -38,6 +39,7 @@ export default function InviteLinkSettingsModal(props: InvitationLinkSettingsMod
showToast(t("invite_link_updated"), "success");
trpcContext.viewer.teams.get.invalidate();
trpcContext.viewer.teams.list.invalidate();
trpcContext.viewer.teams.getMinimal.invalidate();
},
onError: (e) => {
showToast(e.message, "error");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const MakeTeamPrivateSwitch = ({
},
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.getMinimal.invalidate();
showToast(t(isOrg ? "your_org_updated_successfully" : "your_team_updated_successfully"), "success");
},
});
Expand All @@ -43,7 +44,7 @@ const MakeTeamPrivateSwitch = ({
setTeamPrivate(checked);
mutation.mutate({ id: teamId, isPrivate: checked });
}}
switchContainerClassName="mt-6"
switchContainerClassName="my-6"
data-testid="make-team-private-check"
/>
</>
Expand Down
56 changes: 56 additions & 0 deletions packages/features/ee/teams/components/MemberChangeRoleModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,55 @@ type MembershipRoleOption = {
value: MembershipRole;
};

const updateRoleInCache = ({
utils,
teamId,
searchTerm,
role,
memberId,
}: {
utils: ReturnType<typeof trpc.useUtils>;
teamId: number;
searchTerm: string | undefined;
role: MembershipRole;
memberId: number;
}) => {
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.map((member) => ({
...member,
role: member.id === memberId ? role : member.role,
})),
})),
};
}
);
};

export default function MemberChangeRoleModal(props: {
isOpen: boolean;
currentMember: MembershipRole;
memberId: number;
teamId: number;
initialRole: MembershipRole;
onExit: () => void;
searchTerm?: string;
}) {
const { t } = useLocale();

Expand Down Expand Up @@ -48,6 +90,20 @@ export default function MemberChangeRoleModal(props: {
const utils = trpc.useUtils();

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: props.searchTerm,
});

if (previousValue) {
updateRoleInCache({ utils, teamId, memberId, role, searchTerm: props.searchTerm });
}

return { previousValue };
},
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.organizations.listMembers.invalidate();
Expand Down
131 changes: 114 additions & 17 deletions packages/features/ee/teams/components/MemberInvitationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Controller, useForm } from "react-hook-form";
import TeamInviteFromOrg from "@calcom/ee/organizations/components/TeamInviteFromOrg";
import { classNames } from "@calcom/lib";
import { IS_TEAM_BILLING_ENABLED, MAX_NB_INVITES } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc";
Expand Down Expand Up @@ -42,6 +43,7 @@ type MemberInvitationModalProps = {
isPending?: boolean;
disableCopyLink?: boolean;
isOrg?: boolean;
checkMembershipMutation?: boolean;
};

type MembershipRoleOption = {
Expand Down Expand Up @@ -74,17 +76,16 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
enabled: !!session.data?.user?.org,
});

const checkIfMembershipExistsMutation = trpc.viewer.teams.checkIfMembershipExists.useMutation();

// Check current org role and not team role
const isOrgAdminOrOwner =
currentOrg &&
(currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN);

const canSeeOrganization = !!(
props?.orgMembers &&
props.orgMembers?.length > 0 &&
currentOrg?.isPrivate &&
isOrgAdminOrOwner
);
const canSeeOrganization = currentOrg?.isPrivate
? isOrgAdminOrOwner
: !!(props?.orgMembers && props.orgMembers?.length > 0 && isOrgAdminOrOwner);

const [modalImportMode, setModalInputMode] = useState<ModalMode>(
canSeeOrganization ? "ORGANIZATION" : "INDIVIDUAL"
Expand Down Expand Up @@ -136,12 +137,19 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)

const newMemberFormMethods = useForm<NewMemberForm>();

const validateUniqueInvite = (value: string) => {
if (!props?.members?.length) return true;
return !(
props?.members.some((member) => member?.username === value) ||
props?.members.some((member) => member?.email === value)
);
const checkIfMembershipExists = (value: string) => {
if (props.checkMembershipMutation) {
return checkIfMembershipExistsMutation.mutateAsync({
teamId: props.teamId,
value,
});
} else {
if (!props?.members?.length) return false;
return (
props?.members.some((member) => member?.username === value) ||
props?.members.some((member) => member?.email === value)
);
}
};

const handleFileUpload = (e: FileEvent<HTMLInputElement>) => {
Expand Down Expand Up @@ -207,7 +215,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
</span>
) : null
}>
<div>
<div className="max-h-9">
<Label className="sr-only" htmlFor="role">
{t("import_mode")}
</Label>
Expand All @@ -231,11 +239,13 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
control={newMemberFormMethods.control}
rules={{
required: t("enter_email"),
validate: (value) => {
validate: async (value) => {
// orgs can only invite members by email
if (typeof value === "string" && !isEmail(value)) return t("enter_email");
if (typeof value === "string")
return validateUniqueInvite(value) || t("member_already_invited");
if (typeof value === "string") {
const doesInviteExists = await checkIfMembershipExists(value);
return !doesInviteExists || t("member_already_invited");
}
},
}}
render={({ field: { onChange }, fieldState: { error } }) => (
Expand Down Expand Up @@ -437,7 +447,9 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
{t("cancel")}
</Button>
<Button
loading={props.isPending || createInviteMutation.isPending}
loading={
props.isPending || createInviteMutation.isPending || checkIfMembershipExistsMutation.isPending
}
type="submit"
color="primary"
className="me-2 ms-2"
Expand All @@ -450,3 +462,88 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
</Dialog>
);
}

export const MemberInvitationModalWithoutMembers = ({
hideInvitationModal,
showMemberInvitationModal,
teamId,
token,
onSettingsOpen,
}: {
hideInvitationModal: () => void;
showMemberInvitationModal: boolean;
teamId: number;
token?: string;
onSettingsOpen: () => void;
}) => {
const searchParams = useCompatSearchParams();
const { t, i18n } = useLocale();
const utils = trpc.useUtils();

const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation();

const { data: orgMembersNotInThisTeam, isPending: isOrgListLoading } =
trpc.viewer.organizations.getMembers.useQuery(
{
teamIdToExclude: teamId,
distinctUser: true,
},
{
enabled: searchParams !== null && !!teamId && !!showMemberInvitationModal,
}
);

return (
<MemberInvitationModal
isPending={inviteMemberMutation.isPending || isOrgListLoading}
isOpen={showMemberInvitationModal}
orgMembers={orgMembersNotInThisTeam}
teamId={teamId}
token={token}
onExit={hideInvitationModal}
checkMembershipMutation={true}
onSubmit={(values, resetFields) => {
inviteMemberMutation.mutate(
{
teamId,
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
},
{
onSuccess: async (data) => {
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.lazyLoadMembers.invalidate();
await utils.viewer.organizations.getMembers.invalidate();
hideInvitationModal();

if (Array.isArray(data.usernameOrEmail)) {
showToast(
t("email_invite_team_bulk", {
userCount: data.numUsersInvited,
}),
"success"
);
resetFields();
} else {
showToast(
t("email_invite_team", {
email: data.usernameOrEmail,
}),
"success"
);
}
},
onError: (error) => {
showToast(error.message, "error");
},
}
);
}}
onSettingsOpen={() => {
hideInvitationModal();
onSettingsOpen();
}}
/>
);
};
Loading

0 comments on commit 981b017

Please sign in to comment.