feat: add assigned badge to team event types (#27036)

* feat: add assigned badge to team event types

Add an 'Assigned' badge to Round Robin and Collective event types
when the current user is a host/participant of the event type.
This provides visibility to users without edit permissions that
they are included in the rotation.

Changes:
- Add isCurrentUserHost flag in getEventTypesFromGroup handler
- Display blue 'Assigned' badge in event types listing view
- Add i18n translation for 'assigned' text

Co-Authored-By: ali@cal.com <ali@cal.com>

* refactor: remove redundant isTeamEvent check for assigned badge

Co-Authored-By: ali@cal.com <ali@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: ali@cal.com <ali@cal.com>
This commit is contained in:
devin-ai-integration[bot]
2026-01-22 10:16:22 +01:00
committed by GitHub
co-authored by ali@cal.com
parent 3b8b2299af
commit 2a98693dcd
3 changed files with 168 additions and 321 deletions
@@ -3,10 +3,7 @@
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
import {
extractHostTimezone,
filterActiveLinks,
} from "@calcom/lib/hashedLinksUtils";
import { extractHostTimezone, filterActiveLinks } from "@calcom/lib/hashedLinksUtils";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
@@ -63,17 +60,13 @@ import type { FC } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { z } from "zod";
type GetUserEventGroupsResponse =
RouterOutputs["viewer"]["eventTypes"]["getUserEventGroups"];
type GetEventTypesFromGroupsResponse =
RouterOutputs["viewer"]["eventTypes"]["getEventTypesFromGroup"];
type GetUserEventGroupsResponse = RouterOutputs["viewer"]["eventTypes"]["getUserEventGroups"];
type GetEventTypesFromGroupsResponse = RouterOutputs["viewer"]["eventTypes"]["getEventTypesFromGroup"];
type InfiniteEventTypeGroup =
GetUserEventGroupsResponse["eventTypeGroups"][number];
type InfiniteEventTypeGroup = GetUserEventGroupsResponse["eventTypeGroups"][number];
type InfiniteEventType = GetEventTypesFromGroupsResponse["eventTypes"][number];
type EventTypeGroups =
RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
type EventTypeGroup = EventTypeGroups[number];
type EventType = EventTypeGroup["eventTypes"][number];
@@ -86,8 +79,9 @@ interface SearchContextType {
debouncedSearchTerm: string;
}
const SearchContextInternal: React.Context<SearchContextType | undefined> =
createContext<SearchContextType | undefined>(undefined);
const SearchContextInternal: React.Context<SearchContextType | undefined> = createContext<
SearchContextType | undefined
>(undefined);
const useSearchContext = (): SearchContextType => {
const context = useContext(SearchContextInternal);
@@ -120,9 +114,7 @@ const querySchema = z.object({
teamId: z.nullable(z.coerce.number()).optional().default(null),
});
const InfiniteTeamsTab: FC<InfiniteTeamsTabProps> = (
props: InfiniteTeamsTabProps
) => {
const InfiniteTeamsTab: FC<InfiniteTeamsTabProps> = (props: InfiniteTeamsTabProps) => {
const { activeEventTypeGroup } = props;
const { debouncedSearchTerm } = useSearchContext();
const { t } = useLocale();
@@ -140,8 +132,7 @@ const InfiniteTeamsTab: FC<InfiniteTeamsTabProps> = (
refetchOnWindowFocus: true,
refetchOnMount: true,
staleTime: 0,
getNextPageParam: (lastPage: { nextCursor: number | null | undefined }) =>
lastPage.nextCursor,
getNextPageParam: (lastPage: { nextCursor: number | null | undefined }) => lastPage.nextCursor,
}
);
@@ -163,20 +154,18 @@ const InfiniteTeamsTab: FC<InfiniteTeamsTabProps> = (
debouncedSearchTerm={debouncedSearchTerm}
/>
)}
{(query.data?.pages?.[0]?.eventTypes?.length ?? 0) > 0 &&
query.hasNextPage && (
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
<Button
color="minimal"
loading={query.isFetchingNextPage}
onClick={(): void => {
query.fetchNextPage();
}}
>
{t("load_more_results")}
</Button>
</div>
)}
{(query.data?.pages?.[0]?.eventTypes?.length ?? 0) > 0 && query.hasNextPage && (
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
<Button
color="minimal"
loading={query.isFetchingNextPage}
onClick={(): void => {
query.fetchNextPage();
}}>
{t("load_more_results")}
</Button>
</div>
)}
</div>
);
};
@@ -195,53 +184,47 @@ const Item = ({
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
const parsedeventTypeColor = parseEventTypeColor(type.eventTypeColor);
const eventTypeColor =
parsedeventTypeColor &&
parsedeventTypeColor[
hasDarkTheme ? "darkEventTypeColor" : "lightEventTypeColor"
];
parsedeventTypeColor && parsedeventTypeColor[hasDarkTheme ? "darkEventTypeColor" : "lightEventTypeColor"];
const isManagedEventType = type.schedulingType === SchedulingType.MANAGED;
const isRoundRobinOrCollective =
type.schedulingType === SchedulingType.ROUND_ROBIN || type.schedulingType === SchedulingType.COLLECTIVE;
const isCurrentUserHost = "isCurrentUserHost" in type && type.isCurrentUserHost;
const showAssignedBadge = isRoundRobinOrCollective && isCurrentUserHost;
const content = (): JSX.Element => (
<div>
<span
className="text-default break-words font-semibold ltr:mr-1 rtl:ml-1"
data-testid={`event-type-title-${type.id}`}
>
data-testid={`event-type-title-${type.id}`}>
{type.title}
</span>
{group.profile.slug && type.schedulingType !== SchedulingType.MANAGED ? (
<small
className="text-subtle hidden font-normal leading-4 sm:inline"
data-testid={`event-type-slug-${type.id}`}
>
data-testid={`event-type-slug-${type.id}`}>
{`/${group.profile.slug}/${type.slug}`}
</small>
) : null}
{!isManagedEventType && type.hidden && (
<span className="ml-2 text-sm text-gray-400 sm:hidden">
{t("hidden")}
</span>
<span className="ml-2 text-sm text-gray-400 sm:hidden">{t("hidden")}</span>
)}
{readOnly && (
<Badge variant="gray" className="ml-2" data-testid="readonly-badge">
{t("readonly")}
</Badge>
)}
{showAssignedBadge && (
<Badge variant="blue" className="ml-2" data-testid="assigned-badge">
{t("assigned")}
</Badge>
)}
</div>
);
return (
<div
className={classNames(
eventTypeColor && "-ml-3",
"relative flex-1 overflow-hidden pr-4 text-sm"
)}
>
<div className={classNames(eventTypeColor && "-ml-3", "relative flex-1 overflow-hidden pr-4 text-sm")}>
{eventTypeColor && (
<div
className="absolute h-full w-0.5"
style={{ backgroundColor: eventTypeColor }}
/>
<div className="absolute h-full w-0.5" style={{ backgroundColor: eventTypeColor }} />
)}
<div className={classNames(eventTypeColor && "ml-3")}>
{readOnly ? (
@@ -250,40 +233,33 @@ const Item = ({
<EventTypeDescription eventType={type} shortenDescription />
</div>
) : (
<Link
href={`/event-types/${type.id}?tabName=setup`}
title={type.title}
>
<Link href={`/event-types/${type.id}?tabName=setup`} title={type.title}>
<div>
<span
className="text-default break-words font-semibold ltr:mr-1 rtl:ml-1"
data-testid={`event-type-title-${type.id}`}
>
data-testid={`event-type-title-${type.id}`}>
{type.title}
</span>
{group.profile.slug &&
type.schedulingType !== SchedulingType.MANAGED ? (
{group.profile.slug && type.schedulingType !== SchedulingType.MANAGED ? (
<small
className="text-subtle hidden font-normal leading-4 sm:inline"
data-testid={`event-type-slug-${type.id}`}
>
data-testid={`event-type-slug-${type.id}`}>
{`/${group.profile.slug}/${type.slug}`}
</small>
) : null}
{!isManagedEventType && type.hidden && (
<span className="ml-2 text-sm text-gray-400 sm:hidden">
{t("hidden")}
</span>
<span className="ml-2 text-sm text-gray-400 sm:hidden">{t("hidden")}</span>
)}
{readOnly && (
<Badge
variant="gray"
className="ml-2"
data-testid="readonly-badge"
>
<Badge variant="gray" className="ml-2" data-testid="readonly-badge">
{t("readonly")}
</Badge>
)}
{showAssignedBadge && (
<Badge variant="blue" className="ml-2" data-testid="assigned-badge">
{t("assigned")}
</Badge>
)}
</div>
<EventTypeDescription
eventType={{
@@ -318,11 +294,10 @@ export const InfiniteEventTypeList = ({
const [parent] = useAutoAnimate<HTMLUListElement>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
const [deleteDialogTypeSchedulingType, setDeleteDialogSchedulingType] =
useState<SchedulingType | null>(null);
const [privateLinkCopyIndices, setPrivateLinkCopyIndices] = useState<
Record<string, number>
>({});
const [deleteDialogTypeSchedulingType, setDeleteDialogSchedulingType] = useState<SchedulingType | null>(
null
);
const [privateLinkCopyIndices, setPrivateLinkCopyIndices] = useState<Record<string, number>>({});
const utils = trpc.useUtils();
const mutation = trpc.viewer.loggedInViewerRouter.eventTypeOrder.useMutation({
@@ -336,12 +311,11 @@ export const InfiniteEventTypeList = ({
const setHiddenMutation = trpc.viewer.eventTypesHeavy.update.useMutation({
onMutate: async (data: { id: number; hidden?: boolean }) => {
await utils.viewer.eventTypes.getEventTypesFromGroup.cancel();
const previousValue =
utils.viewer.eventTypes.getEventTypesFromGroup.getInfiniteData({
limit: LIMIT,
searchQuery: debouncedSearchTerm,
group: { teamId: group?.teamId, parentId: group?.parentId },
});
const previousValue = utils.viewer.eventTypes.getEventTypesFromGroup.getInfiniteData({
limit: LIMIT,
searchQuery: debouncedSearchTerm,
group: { teamId: group?.teamId, parentId: group?.parentId },
});
if (previousValue) {
await utils.viewer.eventTypes.getEventTypesFromGroup.setInfiniteData(
@@ -362,9 +336,7 @@ export const InfiniteEventTypeList = ({
pages: oldData.pages.map((page) => ({
...page,
eventTypes: page.eventTypes.map((eventType) =>
eventType.id === data.id
? { ...eventType, hidden: !eventType.hidden }
: eventType
eventType.id === data.id ? { ...eventType, hidden: !eventType.hidden } : eventType
),
})),
};
@@ -389,10 +361,7 @@ export const InfiniteEventTypeList = ({
},
});
async function moveEventType(
index: number,
increment: 1 | -1
): Promise<void> {
async function moveEventType(index: number, increment: 1 | -1): Promise<void> {
if (!pages) return;
const newOrder = pages;
const pageNo = Math.floor(index / LIMIT);
@@ -405,8 +374,8 @@ export const InfiniteEventTypeList = ({
? pageNo - 1
: pageNo
: index % LIMIT === LIMIT - 1
? pageNo + 1
: pageNo;
? pageNo + 1
: pageNo;
const newIdx = (index + increment) % LIMIT;
const newPositionEventType = newOrder[newPageNo].eventTypes[newIdx];
@@ -415,12 +384,11 @@ export const InfiniteEventTypeList = ({
newOrder[newPageNo].eventTypes[newIdx] = currentPositionEventType;
await utils.viewer.eventTypes.getEventTypesFromGroup.cancel();
const previousValue =
utils.viewer.eventTypes.getEventTypesFromGroup.getInfiniteData({
limit: LIMIT,
searchQuery: debouncedSearchTerm,
group: { teamId: group?.teamId, parentId: group?.parentId },
});
const previousValue = utils.viewer.eventTypes.getEventTypesFromGroup.getInfiniteData({
limit: LIMIT,
searchQuery: debouncedSearchTerm,
group: { teamId: group?.teamId, parentId: group?.parentId },
});
if (previousValue) {
utils.viewer.eventTypes.getEventTypesFromGroup.setInfiniteData(
@@ -454,17 +422,9 @@ export const InfiniteEventTypeList = ({
}
// inject selection data into url for correct router history
const openDuplicateModal = (
eventType: InfiniteEventType,
group: InfiniteEventTypeGroup
): void => {
const newSearchParams = new URLSearchParams(
searchParams?.toString() ?? undefined
);
function setParamsIfDefined(
key: string,
value: string | number | boolean | null | undefined
) {
const openDuplicateModal = (eventType: InfiniteEventType, group: InfiniteEventTypeGroup): void => {
const newSearchParams = new URLSearchParams(searchParams?.toString() ?? undefined);
function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) {
if (value) newSearchParams.set(key, value.toString());
if (value === null) newSearchParams.delete(key);
}
@@ -485,12 +445,11 @@ export const InfiniteEventTypeList = ({
},
onMutate: async ({ id }) => {
await utils.viewer.eventTypes.getEventTypesFromGroup.cancel();
const previousValue =
utils.viewer.eventTypes.getEventTypesFromGroup.getInfiniteData({
limit: LIMIT,
searchQuery: debouncedSearchTerm,
group: { teamId: group?.teamId, parentId: group?.parentId },
});
const previousValue = utils.viewer.eventTypes.getEventTypesFromGroup.getInfiniteData({
limit: LIMIT,
searchQuery: debouncedSearchTerm,
group: { teamId: group?.teamId, parentId: group?.parentId },
});
if (previousValue) {
await utils.viewer.eventTypes.getEventTypesFromGroup.setInfiniteData(
@@ -554,24 +513,16 @@ export const InfiniteEventTypeList = ({
return group.teamId ? (
<EmptyEventTypeList group={group} searchTerm={debouncedSearchTerm} />
) : !group.profile.eventTypesLockedByOrg ? (
<CreateFirstEventTypeView
slug={group.profile.slug ?? ""}
searchTerm={debouncedSearchTerm}
/>
<CreateFirstEventTypeView slug={group.profile.slug ?? ""} searchTerm={debouncedSearchTerm} />
) : (
<></>
);
}
const firstItem = pages?.[0]?.eventTypes[0];
const lastItem =
pages?.[pages.length - 1]?.eventTypes[
pages?.[pages.length - 1].eventTypes.length - 1
];
const lastItem = pages?.[pages.length - 1]?.eventTypes[pages?.[pages.length - 1].eventTypes.length - 1];
const isManagedEventPrefix = () => {
return deleteDialogTypeSchedulingType === SchedulingType.MANAGED
? "_managed"
: "";
return deleteDialogTypeSchedulingType === SchedulingType.MANAGED ? "_managed" : "";
};
const userTimezone = extractHostTimezone({
@@ -584,35 +535,23 @@ export const InfiniteEventTypeList = ({
return (
<div className="bg-default border-subtle flex flex-col overflow-hidden rounded-md border">
<ul
ref={parent}
className="divide-subtle static! w-full divide-y"
data-testid="event-types"
>
<ul ref={parent} className="divide-subtle static! w-full divide-y" data-testid="event-types">
{pages.map((page, pageIdx) => {
return page?.eventTypes?.map((type, index) => {
const embedLink = `${group.profile.slug}/${type.slug}`;
const calLink = `${bookerUrl}/${embedLink}`;
const activeHashedLinks = type.hashedLink
? filterActiveLinks(type.hashedLink, userTimezone)
: [];
const activeHashedLinks = type.hashedLink ? filterActiveLinks(type.hashedLink, userTimezone) : [];
// Ensure index is within bounds for active links
const currentIndex = privateLinkCopyIndices[type.slug] ?? 0;
const safeIndex =
activeHashedLinks.length > 0
? currentIndex % activeHashedLinks.length
: 0;
const safeIndex = activeHashedLinks.length > 0 ? currentIndex % activeHashedLinks.length : 0;
const isPrivateURLEnabled =
activeHashedLinks.length > 0
? activeHashedLinks[safeIndex]?.link
: "";
activeHashedLinks.length > 0 ? activeHashedLinks[safeIndex]?.link : "";
const placeholderHashedLink = `${bookerUrl}/d/${isPrivateURLEnabled}/${type.slug}`;
const isManagedEventType =
type.schedulingType === SchedulingType.MANAGED;
const isManagedEventType = type.schedulingType === SchedulingType.MANAGED;
const isChildrenManagedEventType =
type.metadata?.managedEventConfig !== undefined &&
type.schedulingType !== SchedulingType.MANAGED;
@@ -622,26 +561,18 @@ export const InfiniteEventTypeList = ({
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden px-4 py-4 sm:px-6">
{!(firstItem && firstItem.id === type.id) && (
<ArrowButton
onClick={() =>
moveEventType(LIMIT * pageIdx + index, -1)
}
onClick={() => moveEventType(LIMIT * pageIdx + index, -1)}
arrowDirection="up"
/>
)}
{!(lastItem && lastItem.id === type.id) && (
<ArrowButton
onClick={() =>
moveEventType(LIMIT * pageIdx + index, 1)
}
onClick={() => moveEventType(LIMIT * pageIdx + index, 1)}
arrowDirection="down"
/>
)}
<MemoizedItem
type={type}
group={group}
readOnly={readOnly}
/>
<MemoizedItem type={type} group={group} readOnly={readOnly} />
<div className="mt-4 hidden sm:mt-0 sm:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
{!!type.teamId && !isManagedEventType && (
@@ -653,34 +584,23 @@ export const InfiniteEventTypeList = ({
users={type?.users ?? []}
/>
)}
{isManagedEventType &&
type?.children &&
type.children?.length > 0 && (
<UserAvatarGroup
className="relative right-3"
size="sm"
truncateAfter={4}
hideTruncatedAvatarsCount={true}
users={
type?.children.flatMap((ch) => ch.users) ?? []
}
/>
)}
{isManagedEventType && type?.children && type.children?.length > 0 && (
<UserAvatarGroup
className="relative right-3"
size="sm"
truncateAfter={4}
hideTruncatedAvatarsCount={true}
users={type?.children.flatMap((ch) => ch.users) ?? []}
/>
)}
<div className="flex items-center justify-between space-x-2 rtl:space-x-reverse">
{!isManagedEventType && (
<>
{type.hidden && (
<span className="text-sm text-gray-400">
{t("hidden")}
</span>
)}
{type.hidden && <span className="text-sm text-gray-400">{t("hidden")}</span>}
<Tooltip
content={
type.hidden
? t("show_eventtype_on_profile")
: t("hide_from_profile")
}
>
type.hidden ? t("show_eventtype_on_profile") : t("hide_from_profile")
}>
<div className="self-center rounded-md p-2">
<Switch
name="Hidden"
@@ -725,25 +645,17 @@ export const InfiniteEventTypeList = ({
</Tooltip>
{isPrivateURLEnabled && (
<Tooltip
content={t("copy_private_link_to_event")}
>
<Tooltip content={t("copy_private_link_to_event")}>
<Button
color="secondary"
variant="icon"
StartIcon="venetian-mask"
onClick={() => {
showToast(
t("private_link_copied"),
"success"
);
showToast(t("private_link_copied"), "success");
copyToClipboard(placeholderHashedLink);
setPrivateLinkCopyIndices((prev) => {
const prevIndex =
prev[type.slug] ?? 0;
const nextIndex =
(prevIndex + 1) %
activeHashedLinks.length;
const prevIndex = prev[type.slug] ?? 0;
const nextIndex = (prevIndex + 1) % activeHashedLinks.length;
return {
...prev,
[type.slug]: nextIndex,
@@ -756,10 +668,7 @@ export const InfiniteEventTypeList = ({
</>
)}
<Dropdown modal={false}>
<DropdownMenuTrigger
asChild
data-testid={`event-type-options-${type.id}`}
>
<DropdownMenuTrigger asChild data-testid={`event-type-options-${type.id}`}>
<Button
type="button"
variant="icon"
@@ -776,33 +685,25 @@ export const InfiniteEventTypeList = ({
type="button"
data-testid={`event-type-edit-${type.id}`}
StartIcon="pencil"
onClick={() =>
router.push(`/event-types/${type.id}`)
}
>
onClick={() => router.push(`/event-types/${type.id}`)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{!readOnly &&
!isManagedEventType &&
!isChildrenManagedEventType && (
<>
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
data-testid={`event-type-duplicate-${type.id}`}
StartIcon="copy"
onClick={() =>
openDuplicateModal(type, group)
}
>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
<>
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
data-testid={`event-type-duplicate-${type.id}`}
StartIcon="copy"
onClick={() => openDuplicateModal(type, group)}>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
{!isManagedEventType && (
<DropdownMenuItem className="outline-none">
<EventTypeEmbedButton
@@ -812,8 +713,7 @@ export const InfiniteEventTypeList = ({
StartIcon="code"
className="w-full rounded-none"
embedUrl={encodeURIComponent(embedLink)}
eventId={type.id}
>
eventId={type.id}>
{t("embed")}
</EventTypeEmbedButton>
</DropdownMenuItem>
@@ -828,13 +728,10 @@ export const InfiniteEventTypeList = ({
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
setDeleteDialogSchedulingType(
type.schedulingType
);
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon="trash"
className="w-full rounded-t-none"
>
className="w-full rounded-t-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
@@ -849,16 +746,8 @@ export const InfiniteEventTypeList = ({
</div>
<div className="min-w-9 mx-5 flex sm:hidden">
<Dropdown>
<DropdownMenuTrigger
asChild
data-testid={`event-type-options-${type.id}`}
>
<Button
type="button"
variant="icon"
color="secondary"
StartIcon="ellipsis"
/>
<DropdownMenuTrigger asChild data-testid={`event-type-options-${type.id}`}>
<Button type="button" variant="icon" color="secondary" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
@@ -869,8 +758,7 @@ export const InfiniteEventTypeList = ({
href={calLink}
target="_blank"
StartIcon="external-link"
className="w-full rounded-none"
>
className="w-full rounded-none">
{t("preview")}
</DropdownItem>
</DropdownMenuItem>
@@ -882,8 +770,7 @@ export const InfiniteEventTypeList = ({
showToast(t("link_copied"), "success");
}}
StartIcon="clipboard"
className="w-full rounded-none text-left"
>
className="w-full rounded-none text-left">
{t("copy_link")}
</DropdownItem>
</DropdownMenuItem>
@@ -902,16 +789,11 @@ export const InfiniteEventTypeList = ({
}),
url: calLink,
})
.then(() =>
showToast(t("link_shared"), "success")
)
.catch(() =>
showToast(t("failed"), "error")
);
.then(() => showToast(t("link_shared"), "success"))
.catch(() => showToast(t("failed"), "error"));
}}
StartIcon="upload"
className="w-full rounded-none"
>
className="w-full rounded-none">
{t("share")}
</DropdownItem>
</DropdownMenuItem>
@@ -919,31 +801,23 @@ export const InfiniteEventTypeList = ({
{!readOnly && (
<DropdownMenuItem className="outline-none">
<DropdownItem
onClick={() =>
router.push(`/event-types/${type.id}`)
}
onClick={() => router.push(`/event-types/${type.id}`)}
StartIcon="pencil"
className="w-full rounded-none"
>
className="w-full rounded-none">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{!readOnly &&
!isManagedEventType &&
!isChildrenManagedEventType && (
<DropdownMenuItem className="outline-none">
<DropdownItem
onClick={() =>
openDuplicateModal(type, group)
}
StartIcon="copy"
data-testid={`event-type-duplicate-${type.id}`}
>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
)}
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
<DropdownMenuItem className="outline-none">
<DropdownItem
onClick={() => openDuplicateModal(type, group)}
StartIcon="copy"
data-testid={`event-type-duplicate-${type.id}`}>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
)}
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{!readOnly && !isChildrenManagedEventType && (
<>
@@ -953,13 +827,10 @@ export const InfiniteEventTypeList = ({
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
setDeleteDialogSchedulingType(
type.schedulingType
);
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon="trash"
className="w-full rounded-t-none"
>
className="w-full rounded-t-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
@@ -971,11 +842,8 @@ export const InfiniteEventTypeList = ({
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
className="mt-2 inline cursor-pointer self-center pr-2 "
>
{type.hidden
? t("show_eventtype_on_profile")
: t("hide_from_profile")}
className="mt-2 inline cursor-pointer self-center pr-2 ">
{type.hidden ? t("show_eventtype_on_profile") : t("hide_from_profile")}
</Skeleton>
<Switch
id="hiddenSwitch"
@@ -1011,8 +879,7 @@ export const InfiniteEventTypeList = ({
onConfirm={(e) => {
e.preventDefault();
deleteEventTypeHandler(deleteDialogTypeId);
}}
>
}}>
<p className="mt-5">
{deleteDialogTypeSchedulingType === SchedulingType.MANAGED ? (
<ul className="ml-4 list-disc">
@@ -1029,23 +896,13 @@ export const InfiniteEventTypeList = ({
);
};
const CreateFirstEventTypeView = ({
slug,
searchTerm,
}: {
slug: string;
searchTerm?: string;
}) => {
const CreateFirstEventTypeView = ({ slug, searchTerm }: { slug: string; searchTerm?: string }) => {
const { t } = useLocale();
return (
<EmptyScreen
Icon="link"
headline={
searchTerm
? t("no_result_found_for", { searchTerm })
: t("new_event_type_heading")
}
headline={searchTerm ? t("no_result_found_for", { searchTerm }) : t("new_event_type_heading")}
description={t("new_event_type_description")}
className="mb-16"
buttonRaw={
@@ -1081,9 +938,7 @@ const CTA = ({ profileOptions }: { profileOptions: ProfileOption[] }) => {
data-testid="new-event-type"
subtitle={t("create_event_on").toUpperCase()}
options={profileOptions}
createDialog={() => (
<CreateEventTypeDialog profileOptions={profileOptions} />
)}
createDialog={() => <CreateEventTypeDialog profileOptions={profileOptions} />}
/>
</div>
);
@@ -1101,18 +956,13 @@ const EmptyEventTypeList = ({
<>
<EmptyScreen
Icon="link"
headline={
searchTerm
? t("no_result_found_for", { searchTerm })
: t("team_no_event_types")
}
headline={searchTerm ? t("no_result_found_for", { searchTerm }) : t("team_no_event_types")}
description={t("new_team_event_type_description")}
className="mb-16"
buttonRaw={
<Button
href={`?dialog=new&eventPage=${group.profile.slug}&teamId=${group.teamId}`}
variant="button"
>
variant="button">
{t("create")}
</Button>
}
@@ -1141,8 +991,7 @@ const InfiniteScrollMain = ({
}));
const activeEventTypeGroup =
eventTypeGroups.filter((item) => item.teamId === data.teamId) ??
eventTypeGroups[0];
eventTypeGroups.filter((item) => item.teamId === data.teamId) ?? eventTypeGroups[0];
const bookerUrl = orgBranding ? orgBranding?.fullDomain : WEBSITE_URL;
@@ -1151,8 +1000,7 @@ const InfiniteScrollMain = ({
// This keeps the app working for personal event types that were not migrated to the org (rare)
if (
orgBranding &&
(activeEventTypeGroup[0].teamId === orgBranding.id ||
activeEventTypeGroup[0].parentId === orgBranding.id)
(activeEventTypeGroup[0].teamId === orgBranding.id || activeEventTypeGroup[0].parentId === orgBranding.id)
) {
activeEventTypeGroup[0].bookerUrl = bookerUrl;
}
@@ -1160,12 +1008,8 @@ const InfiniteScrollMain = ({
return (
<>
{eventTypeGroups.length > 1 && <HorizontalTabs tabs={tabs} />}
{eventTypeGroups.length >= 1 && (
<InfiniteTeamsTab activeEventTypeGroup={activeEventTypeGroup[0]} />
)}
{eventTypeGroups.length === 0 && (
<CreateFirstEventTypeView slug={profiles[0].slug ?? ""} />
)}
{eventTypeGroups.length >= 1 && <InfiniteTeamsTab activeEventTypeGroup={activeEventTypeGroup[0]} />}
{eventTypeGroups.length === 0 && <CreateFirstEventTypeView slug={profiles[0].slug ?? ""} />}
<EventTypeEmbedDialog />
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog />}
</>
@@ -1200,8 +1044,7 @@ export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
// Fallback: allow admin and owner roles
return (
profile.membershipRole === MembershipRole.ADMIN ||
profile.membershipRole === MembershipRole.OWNER
profile.membershipRole === MembershipRole.ADMIN || profile.membershipRole === MembershipRole.OWNER
);
})
?.map((profile) => {
@@ -1244,9 +1087,7 @@ const EventTypesPage = ({ userEventGroupsData, user }: Props) => {
useEffect(() => {
setShowProfileBanner(
!!orgBranding &&
!document.cookie.includes("calcom-profile-banner=1") &&
!user?.completedOnboarding
!!orgBranding && !document.cookie.includes("calcom-profile-banner=1") && !user?.completedOnboarding
);
}, [orgBranding, user]);
@@ -1258,7 +1099,6 @@ const EventTypesPage = ({ userEventGroupsData, user }: Props) => {
);
};
export const SearchContext: React.Context<SearchContextType | undefined> =
SearchContextInternal;
export const SearchContext: React.Context<SearchContextType | undefined> = SearchContextInternal;
export default EventTypesPage;
@@ -987,6 +987,7 @@
"url": "URL",
"hidden": "Hidden",
"readonly": "Read only",
"assigned": "Assigned",
"one_time_link": "One-time link",
"plan_description": "You're currently on the {{plan}} plan.",
"plan_upgrade_invitation": "Upgrade your account to the PRO plan to unlock all of the features we have to offer.",
@@ -2,10 +2,9 @@ import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/ev
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { TrpcSessionUser } from "../../../types";
import type { TGetEventTypesFromGroupSchema } from "./getByViewer.schema";
import { mapEventType } from "./util";
@@ -22,12 +21,13 @@ type GetByViewerOptions = {
type EventType = Awaited<ReturnType<EventTypeRepository["findAllByUpId"]>>[number];
type MappedEventType = Awaited<ReturnType<typeof mapEventType>>;
type MappedEventTypeWithHostFlag = MappedEventType & { isCurrentUserHost: boolean };
export const getEventTypesFromGroup = async ({
ctx,
input,
}: GetByViewerOptions): Promise<{
eventTypes: MappedEventType[];
eventTypes: MappedEventTypeWithHostFlag[];
nextCursor: number | null | undefined;
}> => {
await checkRateLimitAndThrowError({
@@ -144,7 +144,7 @@ export const getEventTypesFromGroup = async ({
eventTypes.push(...teamEventTypes);
}
let nextCursor: number | null | undefined = undefined;
let nextCursor: number | null | undefined;
if (eventTypes.length > limit) {
const nextItem = eventTypes.pop();
nextCursor = nextItem?.id;
@@ -152,6 +152,12 @@ export const getEventTypesFromGroup = async ({
const mappedEventTypes: MappedEventType[] = await Promise.all(eventTypes.map(mapEventType));
// Add isCurrentUserHost flag to each event type
const eventTypesWithHostFlag = mappedEventTypes.map((eventType) => ({
...eventType,
isCurrentUserHost: eventType.hosts?.some((host) => host.user.id === ctx.user.id) ?? false,
}));
const membership = await prisma.membership.findFirst({
where: {
userId: ctx.user.id,
@@ -169,11 +175,11 @@ export const getEventTypesFromGroup = async ({
});
if (membership && membership.team.isPrivate)
mappedEventTypes.forEach((evType) => {
eventTypesWithHostFlag.forEach((evType) => {
evType.users = [];
evType.hosts = [];
evType.children = [];
});
return { eventTypes: mappedEventTypes, nextCursor: nextCursor ?? undefined };
return { eventTypes: eventTypesWithHostFlag, nextCursor: nextCursor ?? undefined };
};