fix: add missing options for team memberlist table (#17017)

* fix: missing option for team memberlist table

* update
This commit is contained in:
Anik Dhabal Babu
2024-10-09 18:23:45 +01:00
committed by GitHub
parent 06429e27fb
commit 83d7bf322a
8 changed files with 382 additions and 2 deletions
@@ -0,0 +1,202 @@
import type { Table } from "@tanstack/react-table";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import {
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
showToast,
PopoverContent,
PopoverTrigger,
Icon,
} from "@calcom/ui";
import type { User } from "./MemberListItem";
interface Props {
table: Table<User>;
teamId: number;
}
export function EventTypesList({ table, teamId }: Props) {
const { t } = useLocale();
const utils = trpc.useUtils();
const { data } = trpc.viewer.eventTypes.getByViewer.useQuery({
filters: { teamIds: [teamId], schedulingTypes: [SchedulingType.ROUND_ROBIN] },
});
const addMutation = trpc.viewer.teams.addMembersToEventTypes.useMutation({
onError: (error) => {
showToast(error.message, "error");
},
onSuccess: () => {
showToast(
`${selectedUsers.length} users added to ${Array.from(selectedEvents).length} events`,
"success"
);
utils.viewer.teams.lazyLoadMembers.invalidate();
utils.viewer.eventTypes.invalidate();
// Clear the selected values
setSelectedEvents(new Set());
table.toggleAllRowsSelected(false);
},
});
const removeHostsMutation = trpc.viewer.teams.removeHostsFromEventTypes.useMutation({
onError: (error) => {
showToast(error.message, "error");
},
onSuccess: () => {
showToast(
`${selectedUsers.length} users were removed from ${Array.from(removeHostFromEvents).length} events`,
"success"
);
utils.viewer.teams.lazyLoadMembers.invalidate();
utils.viewer.eventTypes.invalidate();
// Clear the selected values
setRemoveHostFromEvents(new Set());
table.toggleAllRowsSelected(false);
},
});
const [selectedEvents, setSelectedEvents] = useState<Set<number>>(new Set());
const [removeHostFromEvents, setRemoveHostFromEvents] = useState<Set<number>>(new Set());
const eventTypeGroups = data?.eventTypeGroups;
const selectedUsers = table.getSelectedRowModel().flatRows.map((row) => row.original);
// Add value array to the set
const addValue = (set: Set<number>, setSet: Dispatch<SetStateAction<Set<number>>>, value: number[]) => {
const updatedSet = new Set(set);
value.forEach((v) => updatedSet.add(v));
setSet(updatedSet);
};
// Remove value array from the set
const removeValue = (set: Set<number>, setSet: Dispatch<SetStateAction<Set<number>>>, value: number[]) => {
const updatedSet = new Set(set);
value.forEach((v) => updatedSet.delete(v));
setSet(updatedSet);
};
return (
<>
<Popover>
<PopoverTrigger asChild>
<Button StartIcon="link">{t("add_to_event_type")}</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0 shadow-md" align="start" sideOffset={12}>
<Command>
<CommandInput placeholder={t("search")} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{eventTypeGroups &&
eventTypeGroups.map((data) => {
const events = data.eventTypes;
const teamId = data.teamId;
if (events.length === 0 || !teamId) return null;
return (
<>
{events.map((event) => {
const hosts = event.hosts;
const areAllUsersHostForEventType = selectedUsers.every((user) =>
hosts.some((host) => host.userId === user.id)
);
const isSelected =
(selectedEvents.has(event.id) || areAllUsersHostForEventType) &&
!removeHostFromEvents.has(event.id);
return (
<ListItem
onSelect={() => {
if (!isSelected) {
if (areAllUsersHostForEventType) {
removeValue(removeHostFromEvents, setRemoveHostFromEvents, [event.id]);
} else {
// Add current event
addValue(selectedEvents, setSelectedEvents, [event.id]);
}
} else {
if (areAllUsersHostForEventType) {
// remove selected users as hosts
addValue(removeHostFromEvents, setRemoveHostFromEvents, [event.id]);
} else {
// remove current event
removeValue(selectedEvents, setSelectedEvents, [event.id]);
}
}
}}
key={event.id}
text={event.title}
isSelected={isSelected}
/>
);
})}
</>
);
})}
</CommandGroup>
</CommandList>
</Command>
<div className="my-1.5 flex w-full">
<Button
className="ml-auto mr-1.5 rounded-md"
size="sm"
onClick={() => {
const userIds = selectedUsers.map((user) => user.id);
if (selectedEvents.size > 0) {
addMutation.mutateAsync({
userIds: userIds,
eventTypeIds: Array.from(selectedEvents),
teamId,
});
}
if (removeHostFromEvents.size > 0) {
removeHostsMutation.mutateAsync({
userIds,
eventTypeIds: Array.from(removeHostFromEvents),
teamId,
});
}
}}>
{t("apply")}
</Button>
</div>
</PopoverContent>
</Popover>
</>
);
}
interface ListItemProps {
text: string;
isSelected: boolean;
onSelect: () => void;
}
const ListItem = ({ onSelect, text, isSelected }: ListItemProps) => {
return (
<CommandItem key={text} onSelect={onSelect}>
{text}
<div
className={classNames(
"border-subtle ml-auto flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
)}>
<Icon name="check" className={classNames("h-4 w-4")} />
</div>
</CommandItem>
);
};
@@ -1,12 +1,16 @@
import { keepPreviousData } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import type { ColumnDef, Table } from "@tanstack/react-table";
import classNames from "classnames";
import { m } from "framer-motion";
import { signIn } from "next-auth/react";
import { useSession } from "next-auth/react";
import { useMemo, useRef, useReducer, useState, useEffect, useCallback } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -33,6 +37,7 @@ import {
import DeleteBulkTeamMembers from "./DeleteBulkTeamMembers";
import { EditMemberSheet } from "./EditMemberSheet";
import { EventTypesList } from "./EventTypesList";
import TeamAvailabilityModal from "./TeamAvailabilityModal";
interface Props {
@@ -111,8 +116,12 @@ export default function MemberListItem(props: Props) {
const { data: session } = useSession();
const utils = trpc.useUtils();
const [state, dispatch] = useReducer(reducer, initialState);
const orgBranding = useOrgBranding();
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
const tableContainerRef = useRef<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState("");
const [dynamicLinkVisible, setDynamicLinkVisible] = useState(false);
const { copyToClipboard, isCopied } = useCopy();
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const { data, isPending, fetchNextPage, isFetching } = trpc.viewer.teams.lazyLoadMembers.useInfiniteQuery(
@@ -564,6 +573,19 @@ export default function MemberListItem(props: Props) {
data-testid="team-member-list-container"
onSearch={(value) => setSearchTerm(value)}
selectionOptions={[
{
type: "action",
icon: "handshake",
label: "Group Meeting",
needsXSelected: 2,
onClick: () => {
setDynamicLinkVisible((old) => !old);
},
},
{
type: "render",
render: (table) => <EventTypesList table={table} teamId={props.team.id} />,
},
{
type: "render",
render: (table) => (
@@ -576,6 +598,50 @@ export default function MemberListItem(props: Props) {
),
},
]}
renderAboveSelection={(table: Table<User>) => {
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
const isVisible = numberOfSelectedRows >= 2 && dynamicLinkVisible;
const users = table
.getSelectedRowModel()
.flatRows.map((row) => row.original.username)
.filter((u) => u !== null);
const usersNameAsString = users.join("+");
const dynamicLinkOfSelectedUsers = `${domain}/${usersNameAsString}`;
const domainWithoutHttps = dynamicLinkOfSelectedUsers.replace(/https?:\/\//g, "");
return (
<>
{isVisible ? (
<m.div
layout
className="bg-brand-default text-inverted item-center animate-fade-in-bottom hidden w-full gap-1 rounded-lg p-2 text-sm font-medium leading-none md:flex">
<div className="w-[300px] items-center truncate p-2">
<p>{domainWithoutHttps}</p>
</div>
<div className="ml-auto flex items-center">
<Button
StartIcon="copy"
size="sm"
onClick={() => copyToClipboard(dynamicLinkOfSelectedUsers)}>
{!isCopied ? t("copy") : t("copied")}
</Button>
<Button
EndIcon="external-link"
size="sm"
href={dynamicLinkOfSelectedUsers}
target="_blank"
rel="noopener noreferrer">
Open
</Button>
</div>
</m.div>
) : null}
</>
);
}}
tableContainerRef={tableContainerRef}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
tableCTA={
@@ -44,7 +44,7 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
if (!booking) throw new TRPCError({ code: "NOT_FOUND", message: "booking_not_found" });
const isTeamAdminOrOwner =
(await isTeamAdmin(user.id, booking.eventType?.teamId ?? 0)) &&
(await isTeamAdmin(user.id, booking.eventType?.teamId ?? 0)) ||
(await isTeamOwner(user.id, booking.eventType?.teamId ?? 0));
const isOrganizer = booking.userId === user.id;
@@ -1,6 +1,7 @@
import authedProcedure from "../../../procedures/authedProcedure";
import { importHandler, router } from "../../../trpc";
import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema";
import { ZAddMembersToEventTypes } from "./addMembersToEventTypes.schema";
import { ZChangeMemberRoleInputSchema } from "./changeMemberRole.schema";
import { ZCheckIfMembershipExistsInputSchema } from "./checkIfMembershipExists.schema";
import { ZCreateInputSchema } from "./create.schema";
@@ -20,6 +21,7 @@ import { ZGetListSchema } from "./list.schema";
import { ZListMembersInputSchema } from "./listMembers.schema";
import { hasTeamPlan } from "./procedures/hasTeamPlan";
import { ZPublishInputSchema } from "./publish.schema";
import { ZRemoveHostsFromEventTypes } from "./removeHostsFromEventTypes.schema";
import { ZRemoveMemberInputSchema } from "./removeMember.schema";
import { ZResendInvitationInputSchema } from "./resendInvitation.schema";
import { ZRoundRobinReassignInputSchema } from "./roundRobin/roundRobinReassign.schema";
@@ -194,4 +196,18 @@ export const viewerTeamsRouter = router({
);
return handler(opts);
}),
addMembersToEventTypes: authedProcedure.input(ZAddMembersToEventTypes).mutation(async (opts) => {
const handler = await importHandler(
namespaced("addMembersToEventTypes"),
() => import("./addMembersToEventTypes.handler")
);
return handler(opts);
}),
removeHostsFromEventTypes: authedProcedure.input(ZRemoveHostsFromEventTypes).mutation(async (opts) => {
const handler = await importHandler(
namespaced("removeHostsFromEventTypes"),
() => import("./removeHostsFromEventTypes.handler")
);
return handler(opts);
}),
});
@@ -0,0 +1,42 @@
import type { Prisma } from "@prisma/client";
import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TAddMembersToEventTypes } from "./addMembersToEventTypes.schema";
type AddBulkToEventTypeHandler = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TAddMembersToEventTypes;
};
export async function addMembersToEventTypesHandler({ ctx, input }: AddBulkToEventTypeHandler) {
const { eventTypeIds, userIds, teamId } = input;
const isTeamAdminOrOwner =
(await isTeamAdmin(ctx.user.id, teamId)) || (await isTeamOwner(ctx.user.id, teamId));
// check if user is admin or owner of team
if (!isTeamAdminOrOwner) throw new TRPCError({ code: "UNAUTHORIZED" });
const data: Prisma.HostCreateManyInput[] = eventTypeIds.flatMap((eventId) =>
userIds.map((userId) => ({
eventTypeId: eventId,
userId: userId,
priority: 2, // Default medium priority
}))
);
return await prisma.host.createMany({
data,
// skip if user already a host in eventType
skipDuplicates: true,
});
}
export default addMembersToEventTypesHandler;
@@ -0,0 +1,9 @@
import { z } from "zod";
export const ZAddMembersToEventTypes = z.object({
userIds: z.array(z.number()),
teamId: z.number(),
eventTypeIds: z.array(z.number()),
});
export type TAddMembersToEventTypes = z.infer<typeof ZAddMembersToEventTypes>;
@@ -0,0 +1,36 @@
import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TRemoveHostsFromEventTypes } from "./removeHostsFromEventTypes.schema";
type RemoveHostsFromEventTypes = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TRemoveHostsFromEventTypes;
};
export async function removeHostsFromEventTypesHandler({ ctx, input }: RemoveHostsFromEventTypes) {
const { userIds, eventTypeIds, teamId } = input;
const isTeamAdminOrOwner =
(await isTeamAdmin(ctx.user.id, teamId)) || (await isTeamOwner(ctx.user.id, teamId));
// check if user is admin or owner of team
if (!isTeamAdminOrOwner) throw new TRPCError({ code: "UNAUTHORIZED" });
return await prisma.host.deleteMany({
where: {
eventTypeId: {
in: eventTypeIds,
},
userId: {
in: userIds,
},
},
});
}
export default removeHostsFromEventTypesHandler;
@@ -0,0 +1,9 @@
import { z } from "zod";
export const ZRemoveHostsFromEventTypes = z.object({
userIds: z.array(z.number()),
teamId: z.number(),
eventTypeIds: z.array(z.number()),
});
export type TRemoveHostsFromEventTypes = z.infer<typeof ZRemoveHostsFromEventTypes>;