fix: add missing options for team memberlist table (#17017)
* fix: missing option for team memberlist table * update
This commit is contained in:
@@ -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>;
|
||||
Reference in New Issue
Block a user