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:
co-authored by
ali@cal.com
parent
3b8b2299af
commit
2a98693dcd
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user