refactor: event types infinite scroll (#15949)

* chore

* chore

* feat: paginated event types

* fix: event types page

* fix: k bar

* fix: event type order

* fix: type error

* chore: remove commented code

* chore: type err

* chore: type err and feedback

* feat: add old component

* chore: missing import

* fix: add isInfiniteScrollEnabled prop

* chore: type err

* chore: type error

* feat: auto fetch next page when button is in view

* Update packages/lib/server/repository/eventType.ts

* Update packages/lib/server/repository/eventType.ts

* chore: feedback

* fix: managed event types

* fix: Review fixes for event-types-infinite-scroll (#16047)

* Review fixes

* chore: missing import

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>

* chore: type error

* chore

* fix: delete event type

* fix: create event dialog

* fix: create and duplicate dialog

* chore: simplify return type

* fix: type

* chore: invalidate query

* chore: remove query

* fix duplicated event not showing.

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
Udit Takkar
2024-08-05 16:01:03 +04:00
committed by GitHub
co-authored by Udit Takkar Peer Richelsen Hariom Balhara
parent 583661a15d
commit 45db5c0d5e
18 changed files with 1377 additions and 59 deletions
+6 -1
View File
@@ -18,6 +18,11 @@ export const withAppDirSsr =
return {
...props,
// includes dehydratedState required for future page trpcPropvider
...("trpcState" in props && { dehydratedState: props.trpcState }),
...("trpcState" in props && {
dehydratedState: props.trpcState,
}),
...("isInfiniteScrollEnabled" in props && {
isInfiniteScrollEnabled: props.isInfiniteScrollEnabled,
}),
};
};
@@ -1,6 +1,7 @@
import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { ENABLE_INFINITE_EVENT_TYPES_FOR_ORG } from "@calcom/lib/constants";
import { ssrInit } from "@server/lib/ssr";
@@ -17,5 +18,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
};
}
return { props: { trpcState: ssr.dehydrate() } };
const isInfiniteScrollEnabled = session.user?.org?.slug
? ENABLE_INFINITE_EVENT_TYPES_FOR_ORG.includes(session.user.org.slug)
: false;
return { props: { trpcState: ssr.dehydrate(), isInfiniteScrollEnabled } };
};
+3 -2
View File
@@ -2,7 +2,7 @@ import React from "react";
const isInteractionObserverSupported = typeof window !== "undefined" && "IntersectionObserver" in window;
export const useInViewObserver = (onInViewCallback: () => void) => {
export const useInViewObserver = (onInViewCallback: () => void, root?: Element | Document | null) => {
const [node, setRef] = React.useState<HTMLElement | null>(null);
const onInViewCallbackRef = React.useRef(onInViewCallback);
@@ -23,7 +23,8 @@ export const useInViewObserver = (onInViewCallback: () => void) => {
}
},
{
root: document.body,
// We want to accept null as root
root: root !== undefined ? root : document.body,
}
);
observer.observe(node);
@@ -50,7 +50,6 @@ import {
EmptyScreen,
HeadSeo,
HorizontalTabs,
Icon,
Label,
showToast,
Skeleton,
@@ -58,25 +57,25 @@ import {
Tooltip,
ArrowButton,
UserAvatarGroup,
Icon,
} from "@calcom/ui";
import type { AppProps } from "@lib/app-providers";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import useMeQuery from "@lib/hooks/useMeQuery";
import SkeletonLoader from "@components/eventtype/SkeletonLoader";
type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
type GetUserEventGroupsResponse = RouterOutputs["viewer"]["eventTypes"]["getUserEventGroups"];
type GetEventTypesFromGroupsResponse = RouterOutputs["viewer"]["eventTypes"]["getEventTypesFromGroup"];
type InfiniteEventTypeGroup = GetUserEventGroupsResponse["eventTypeGroups"][number];
type InfiniteEventType = GetEventTypesFromGroupsResponse["eventTypes"][number];
type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
type GetByViewerResponse = RouterOutputs["viewer"]["eventTypes"]["getByViewer"] | undefined;
interface EventTypeListHeadingProps {
profile: EventTypeGroupProfile;
membershipCount: number;
teamId?: number | null;
bookerUrl: string;
}
type EventTypeGroup = EventTypeGroups[number];
type EventType = EventTypeGroup["eventTypes"][number];
@@ -91,6 +90,23 @@ type DeNormalizedEventTypeGroup = Omit<EventTypeGroup, "eventTypes"> & {
eventTypes: DeNormalizedEventType[];
};
const LIMIT = 10;
interface EventTypeListHeadingProps {
profile: EventTypeGroupProfile;
membershipCount: number;
teamId?: number | null;
bookerUrl: string;
}
interface InfiniteEventTypeListProps {
group: InfiniteEventTypeGroup;
readOnly: boolean;
bookerUrl: string | null;
pages: { nextCursor: number | undefined; eventTypes: InfiniteEventType[] }[] | undefined;
lockedByOrg?: boolean;
}
interface EventTypeListProps {
group: DeNormalizedEventTypeGroup;
groupIndex: number;
@@ -100,6 +116,10 @@ interface EventTypeListProps {
lockedByOrg?: boolean;
}
interface InfiniteMobileTeamsTabProps {
activeEventTypeGroup: InfiniteEventTypeGroup;
}
interface MobileTeamsTabProps {
eventTypeGroups: DeNormalizedEventTypeGroup[];
}
@@ -136,13 +156,58 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
);
};
const InfiniteMobileTeamsTab: FC<InfiniteMobileTeamsTabProps> = (props) => {
const { activeEventTypeGroup } = props;
const { t } = useLocale();
const query = trpc.viewer.eventTypes.getEventTypesFromGroup.useInfiniteQuery(
{
limit: LIMIT,
group: { teamId: activeEventTypeGroup?.teamId, parentId: activeEventTypeGroup?.parentId },
},
{
refetchOnWindowFocus: true,
staleTime: 1 * 60 * 60 * 1000,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const buttonInView = useInViewObserver(() => {
if (!query.isFetching && query.hasNextPage && query.status === "success") {
query.fetchNextPage();
}
}, null);
return (
<div>
{!!activeEventTypeGroup && (
<InfiniteEventTypeList
pages={query?.data?.pages}
group={activeEventTypeGroup}
bookerUrl={activeEventTypeGroup.bookerUrl}
readOnly={activeEventTypeGroup.metadata.readOnly}
/>
)}
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
<Button
color="minimal"
loading={query.isFetchingNextPage}
disabled={!query.hasNextPage}
onClick={() => query.fetchNextPage()}>
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</div>
);
};
const Item = ({
type,
group,
readOnly,
}: {
type: DeNormalizedEventType;
group: DeNormalizedEventTypeGroup;
type: EventType | InfiniteEventType;
group: EventTypeGroup | InfiniteEventTypeGroup;
readOnly: boolean;
}) => {
const { t } = useLocale();
@@ -233,11 +298,9 @@ export const EventTypeList = ({
onError: async (err) => {
console.error(err.message);
await utils.viewer.eventTypes.getByViewer.cancel();
// REVIEW: Should we invalidate the entire router or just the `getByViewer` query?
await utils.viewer.eventTypes.invalidate();
},
onSettled: () => {
// REVIEW: Should we invalidate the entire router or just the `getByViewer` query?
utils.viewer.eventTypes.invalidate();
},
});
@@ -323,6 +386,8 @@ export const EventTypeList = ({
setParamsIfDefined("id", eventType.id);
setParamsIfDefined("length", eventType.length);
setParamsIfDefined("pageSlug", group.profile.slug);
setParamsIfDefined("teamId", eventType.teamId);
setParamsIfDefined("parentId", eventType.parentId);
router.push(`${pathname}?${newSearchParams.toString()}`);
};
@@ -714,6 +779,546 @@ export const EventTypeList = ({
);
};
export const InfiniteEventTypeList = ({
group,
readOnly,
pages,
bookerUrl,
lockedByOrg,
}: InfiniteEventTypeListProps): JSX.Element => {
const { t } = useLocale();
const router = useRouter();
const pathname = usePathname();
const searchParams = useCompatSearchParams();
const [parent] = useAutoAnimate<HTMLUListElement>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
const [deleteDialogTypeSchedulingType, setDeleteDialogSchedulingType] = useState<SchedulingType | null>(
null
);
const utils = trpc.useUtils();
const mutation = trpc.viewer.eventTypeOrder.useMutation({
onError: async (err) => {
console.error(err.message);
// REVIEW: Should we invalidate the entire router or just the `getByViewer` query?
await utils.viewer.eventTypes.getEventTypesFromGroup.cancel();
},
});
const setHiddenMutation = trpc.viewer.eventTypes.update.useMutation({
onMutate: async (data) => {
await utils.viewer.eventTypes.getEventTypesFromGroup.cancel();
const previousValue = utils.viewer.eventTypes.getEventTypesFromGroup.getInfiniteData({
limit: LIMIT,
group: { teamId: group?.teamId, parentId: group?.parentId },
});
if (previousValue) {
pages?.forEach((page) => {
page?.eventTypes?.forEach((eventType) => {
if (eventType.id === data.id) {
eventType.hidden = !eventType.hidden;
}
});
});
}
return { previousValue };
},
onError: async (err, _, context) => {
if (context?.previousValue) {
utils.viewer.eventTypes.getEventTypesFromGroup.setInfiniteData(
{ limit: LIMIT, group: { teamId: group?.teamId, parentId: group?.parentId } },
context.previousValue
);
}
console.error(err.message);
},
});
async function moveEventType(index: number, increment: 1 | -1) {
if (!pages) return;
const newOrder = pages;
const pageNo = Math.floor(index / LIMIT);
const currentPositionEventType = newOrder[pageNo].eventTypes[index % LIMIT];
const newPageNo =
increment === -1
? pageNo > 0 && index % LIMIT === 0
? pageNo - 1
: pageNo
: index % LIMIT === LIMIT - 1
? pageNo + 1
: pageNo;
const newIdx = (index + increment) % LIMIT;
const newPositionEventType = newOrder[newPageNo].eventTypes[newIdx];
newOrder[pageNo].eventTypes[index % LIMIT] = newPositionEventType;
newOrder[newPageNo].eventTypes[newIdx] = currentPositionEventType;
await utils.viewer.eventTypes.getEventTypesFromGroup.cancel();
const previousValue = utils.viewer.eventTypes.getEventTypesFromGroup.getInfiniteData({
limit: LIMIT,
group: { teamId: group?.teamId, parentId: group?.parentId },
});
if (previousValue) {
utils.viewer.eventTypes.getEventTypesFromGroup.setInfiniteData(
{ limit: LIMIT, group: { teamId: group?.teamId, parentId: group?.parentId } },
(data) => {
return {
pageParams: data?.pageParams ?? [],
pages: newOrder,
};
}
);
}
mutation.mutate({
ids: newOrder.flatMap((page) => page.eventTypes.map((type) => type.id)),
});
}
async function deleteEventTypeHandler(id: number) {
const payload = { id };
deleteMutation.mutate(payload);
}
// inject selection data into url for correct router history
const openDuplicateModal = (eventType: InfiniteEventType, group: InfiniteEventTypeGroup) => {
const newSearchParams = new URLSearchParams(searchParams ?? undefined);
function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) {
if (value) newSearchParams.set(key, value.toString());
if (value === null) newSearchParams.delete(key);
}
setParamsIfDefined("dialog", "duplicate");
setParamsIfDefined("title", eventType.title);
setParamsIfDefined("description", eventType.description);
setParamsIfDefined("slug", eventType.slug);
setParamsIfDefined("id", eventType.id);
setParamsIfDefined("length", eventType.length);
setParamsIfDefined("pageSlug", group.profile.slug);
router.push(`${pathname}?${newSearchParams.toString()}`);
};
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
onSuccess: () => {
showToast(t("event_type_deleted_successfully"), "success");
setDeleteDialogOpen(false);
},
onMutate: async ({ id }) => {
await utils.viewer.eventTypes.getEventTypesFromGroup.cancel();
const previousValue = utils.viewer.eventTypes.getEventTypesFromGroup.getInfiniteData({
limit: LIMIT,
group: { teamId: group?.teamId, parentId: group?.parentId },
});
if (previousValue) {
await utils.viewer.eventTypes.getEventTypesFromGroup.setInfiniteData(
{ limit: LIMIT, group: { teamId: group?.teamId, parentId: group?.parentId } },
(data) => {
if (!data) {
return {
pages: [],
pageParams: [],
};
}
return {
...data,
pages: data.pages.map((page) => ({
...page,
eventTypes: page.eventTypes.filter((type) => type.id !== id),
})),
};
}
);
}
return { previousValue };
},
onError: (err, _, context) => {
if (context?.previousValue) {
utils.viewer.eventTypes.getEventTypesFromGroup.setInfiniteData(
{ limit: LIMIT, group: { teamId: group?.teamId, parentId: group?.parentId } },
context.previousValue
);
}
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
setDeleteDialogOpen(false);
} else if (err instanceof TRPCClientError) {
showToast(err.message, "error");
}
},
});
const [isNativeShare, setNativeShare] = useState(true);
useEffect(() => {
if (!navigator.share) {
setNativeShare(false);
}
}, []);
if (!pages?.[0]?.eventTypes?.length) {
return group.teamId ? (
<EmptyEventTypeList group={group} />
) : !group.profile.eventTypesLockedByOrg ? (
<CreateFirstEventTypeView slug={group.profile.slug ?? ""} />
) : (
<></>
);
}
const firstItem = pages?.[0]?.eventTypes[0];
const lastItem = pages?.[pages.length - 1]?.eventTypes[pages?.[pages.length - 1].eventTypes.length - 1];
const isManagedEventPrefix = () => {
return deleteDialogTypeSchedulingType === SchedulingType.MANAGED ? "_managed" : "";
};
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">
{pages.map((page, pageIdx) => {
return page?.eventTypes?.map((type, index) => {
const embedLink = `${group.profile.slug}/${type.slug}`;
const calLink = `${bookerUrl}/${embedLink}`;
const isPrivateURLEnabled = type.hashedLink?.link;
const placeholderHashedLink = `${WEBSITE_URL}/d/${type.hashedLink?.link}/${type.slug}`;
const isManagedEventType = type.schedulingType === SchedulingType.MANAGED;
const isChildrenManagedEventType =
type.metadata?.managedEventConfig !== undefined &&
type.schedulingType !== SchedulingType.MANAGED;
return (
<li key={type.id}>
<div className="hover:bg-muted flex w-full items-center justify-between transition">
<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)}
arrowDirection="up"
/>
)}
{!(lastItem && lastItem.id === type.id) && (
<ArrowButton
onClick={() => moveEventType(LIMIT * pageIdx + index, 1)}
arrowDirection="down"
/>
)}
<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 && (
<UserAvatarGroup
className="relative right-3"
size="sm"
truncateAfter={4}
hideTruncatedAvatarsCount={true}
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) ?? []}
/>
)}
<div className="flex items-center justify-between space-x-2 rtl:space-x-reverse">
{!isManagedEventType && (
<>
{type.hidden && <Badge variant="gray">{t("hidden")}</Badge>}
<Tooltip
content={
type.hidden ? t("show_eventtype_on_profile") : t("hide_from_profile")
}>
<div className="self-center rounded-md p-2">
<Switch
name="Hidden"
disabled={lockedByOrg}
checked={!type.hidden}
onCheckedChange={() => {
setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden });
}}
/>
</div>
</Tooltip>
</>
)}
<ButtonGroup combined>
{!isManagedEventType && (
<>
<Tooltip content={t("preview")}>
<Button
data-testid="preview-link-button"
color="secondary"
target="_blank"
variant="icon"
href={calLink}
StartIcon="external-link"
/>
</Tooltip>
<Tooltip content={t("copy_link")}>
<Button
color="secondary"
variant="icon"
StartIcon="link"
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(calLink);
}}
/>
</Tooltip>
{isPrivateURLEnabled && (
<Tooltip content={t("copy_private_link_to_event")}>
<Button
color="secondary"
variant="icon"
StartIcon="venetian-mask"
onClick={() => {
showToast(t("private_link_copied"), "success");
navigator.clipboard.writeText(placeholderHashedLink);
}}
/>
</Tooltip>
)}
</>
)}
<Dropdown modal={false}>
<DropdownMenuTrigger asChild data-testid={`event-type-options-${type.id}`}>
<Button
type="button"
variant="icon"
color="secondary"
StartIcon="ellipsis"
className="ltr:radix-state-open:rounded-r-md rtl:radix-state-open:rounded-l-md"
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
{!readOnly && (
<DropdownMenuItem>
<DropdownItem
type="button"
data-testid={`event-type-edit-${type.id}`}
StartIcon="pencil"
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>
</>
)}
{!isManagedEventType && (
<DropdownMenuItem className="outline-none">
<EventTypeEmbedButton
namespace={type.slug}
as={DropdownItem}
type="button"
StartIcon="code"
className="w-full rounded-none"
embedUrl={encodeURIComponent(embedLink)}
eventId={type.id}>
{t("embed")}
</EventTypeEmbedButton>
</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 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon="trash"
className="w-full rounded-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</Dropdown>
</ButtonGroup>
</div>
</div>
</div>
</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>
<DropdownMenuPortal>
<DropdownMenuContent>
{!isManagedEventType && (
<>
<DropdownMenuItem className="outline-none">
<DropdownItem
href={calLink}
target="_blank"
StartIcon="external-link"
className="w-full rounded-none">
{t("preview")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<DropdownItem
data-testid={`event-type-duplicate-${type.id}`}
onClick={() => {
navigator.clipboard.writeText(calLink);
showToast(t("link_copied"), "success");
}}
StartIcon="clipboard"
className="w-full rounded-none text-left">
{t("copy_link")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
{isNativeShare ? (
<DropdownMenuItem className="outline-none">
<DropdownItem
data-testid={`event-type-duplicate-${type.id}`}
onClick={() => {
navigator
.share({
title: t("share"),
text: t("share_event", { appName: APP_NAME }),
url: calLink,
})
.then(() => showToast(t("link_shared"), "success"))
.catch(() => showToast(t("failed"), "error"));
}}
StartIcon="upload"
className="w-full rounded-none">
{t("share")}
</DropdownItem>
</DropdownMenuItem>
) : null}
{!readOnly && (
<DropdownMenuItem className="outline-none">
<DropdownItem
onClick={() => router.push(`/event-types/${type.id}`)}
StartIcon="pencil"
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 is only set when we are on a team - if we are on a user event type null will be the value. */}
{!readOnly && !isChildrenManagedEventType && (
<>
<DropdownMenuItem className="outline-none">
<DropdownItem
color="destructive"
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon="trash"
className="w-full rounded-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
{!isManagedEventType && (
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2">
<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")}
</Skeleton>
<Switch
id="hiddenSwitch"
name="Hidden"
checked={!type.hidden}
onCheckedChange={() => {
setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden });
}}
/>
</div>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
</div>
</div>
</li>
);
});
})}
</ul>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
variety="danger"
title={t(`delete${isManagedEventPrefix()}_event_type`)}
confirmBtnText={t(`confirm_delete_event_type`)}
loadingText={t(`confirm_delete_event_type`)}
isPending={deleteMutation.isPending}
onConfirm={(e) => {
e.preventDefault();
deleteEventTypeHandler(deleteDialogTypeId);
}}>
<p className="mt-5">
<Trans
i18nKey={`delete${isManagedEventPrefix()}_event_type_description`}
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
<ul>
<li>Members assigned to this event type will also have their event types deleted.</li>
<li>
Anyone who they&apos;ve shared their link with will no longer be able to book using it.
</li>
</ul>
</Trans>
</p>
</ConfirmationDialogContent>
</Dialog>
</div>
);
};
const EventTypeListHeading = ({
profile,
membershipCount,
@@ -795,6 +1400,7 @@ const CreateFirstEventTypeView = ({ slug }: { slug: string }) => {
const CTA = ({
profileOptions,
isOrganization,
isInfiniteScrollEnabled,
}: {
profileOptions: {
teamId: number | null | undefined;
@@ -804,6 +1410,7 @@ const CTA = ({
slug: string | null;
}[];
isOrganization: boolean;
isInfiniteScrollEnabled: boolean;
}) => {
const { t } = useLocale();
@@ -815,7 +1422,10 @@ const CTA = ({
subtitle={t("create_event_on").toUpperCase()}
options={profileOptions}
createDialog={() => (
<CreateEventTypeDialog profileOptions={profileOptions} isOrganization={isOrganization} />
<CreateEventTypeDialog
isInfiniteScrollEnabled={isInfiniteScrollEnabled}
profileOptions={profileOptions}
/>
)}
/>
);
@@ -829,7 +1439,7 @@ const Actions = (props: { showDivider: boolean }) => {
);
};
const EmptyEventTypeList = ({ group }: { group: DeNormalizedEventTypeGroup }) => {
const EmptyEventTypeList = ({ group }: { group: EventTypeGroup | InfiniteEventTypeGroup }) => {
const { t } = useLocale();
return (
<>
@@ -941,10 +1551,56 @@ const Main = ({
);
};
const EventTypesPage: React.FC & {
const InfiniteScrollMain = ({
status,
errorMessage,
eventTypeGroups,
profiles,
}: {
status: string;
errorMessage?: string;
eventTypeGroups: GetUserEventGroupsResponse["eventTypeGroups"] | undefined;
profiles: GetUserEventGroupsResponse["profiles"] | undefined;
}) => {
const searchParams = useCompatSearchParams();
const { data } = useTypedQuery(querySchema);
if (status === "error") {
return <Alert severity="error" title="Something went wrong" message={errorMessage} />;
}
if (!eventTypeGroups || !profiles || status === "pending") {
return <SkeletonLoader />;
}
const tabs = eventTypeGroups.map((item) => ({
name: item.profile.name ?? "",
href: item.teamId ? `/event-types?teamId=${item.teamId}` : "/event-types?noTeam",
avatar: item.profile.image,
}));
const activeEventTypeGroup =
eventTypeGroups.filter((item) => item.teamId === data.teamId) ?? eventTypeGroups[0];
return (
<>
{eventTypeGroups.length >= 1 && (
<>
<HorizontalTabs tabs={tabs} />
<InfiniteMobileTeamsTab activeEventTypeGroup={activeEventTypeGroup[0]} />
</>
)}
{eventTypeGroups.length === 0 && <CreateFirstEventTypeView slug={profiles[0].slug ?? ""} />}
<EventTypeEmbedDialog />
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog isInfiniteScrollEnabled />}
</>
);
};
const EventTypesPage: React.FC<{ isInfiniteScrollEnabled?: boolean }> & {
PageWrapper?: AppProps["Component"]["PageWrapper"];
getLayout?: AppProps["Component"]["getLayout"];
} = () => {
} = ({ isInfiniteScrollEnabled = false }) => {
const { t } = useLocale();
const searchParams = useCompatSearchParams();
const { open } = useIntercom();
@@ -956,11 +1612,23 @@ const EventTypesPage: React.FC & {
const filters = getTeamsFiltersFromQuery(routerQuery);
const router = useRouter();
// TODO: Maybe useSuspenseQuery to focus on success case only? Remember that it would crash the page when there is an error in query. Also, it won't support skeleton
const { data, status, error } = trpc.viewer.eventTypes.getByViewer.useQuery(filters && { filters }, {
refetchOnWindowFocus: false,
gcTime: 1 * 60 * 60 * 1000,
staleTime: 1 * 60 * 60 * 1000,
enabled: !isInfiniteScrollEnabled,
});
// TODO: Maybe useSuspenseQuery to focus on success case only? Remember that it would crash the page when there is an error in query. Also, it won't support skeleton
const {
data: getUserEventGroupsData,
status: getUserEventGroupsStatus,
error: getUserEventGroupsStatusError,
} = trpc.viewer.eventTypes.getUserEventGroups.useQuery(filters && { filters }, {
refetchOnWindowFocus: false,
gcTime: 1 * 60 * 60 * 1000,
staleTime: 1 * 60 * 60 * 1000,
enabled: isInfiniteScrollEnabled,
});
useEffect(() => {
@@ -983,20 +1651,32 @@ const EventTypesPage: React.FC & {
);
}, [orgBranding, user]);
const profileOptions = data
? data?.profiles
.filter((profile) => !profile.readOnly)
.filter((profile) => !profile.eventTypesLockedByOrg)
.map((profile) => {
return {
teamId: profile.teamId,
label: profile.name || profile.slug,
image: profile.image,
membershipRole: profile.membershipRole,
slug: profile.slug,
};
})
: [];
const profileOptions =
(data?.profiles
? data?.profiles
.filter((profile) => !profile.readOnly)
.filter((profile) => !profile.eventTypesLockedByOrg)
.map((profile) => {
return {
teamId: profile.teamId,
label: profile.name || profile.slug,
image: profile.image,
membershipRole: profile.membershipRole,
slug: profile.slug,
};
})
: getUserEventGroupsData?.profiles
?.filter((profile) => !profile.readOnly)
?.filter((profile) => !profile.eventTypesLockedByOrg)
?.map((profile) => {
return {
teamId: profile.teamId,
label: profile.name || profile.slug,
image: profile.image,
membershipRole: profile.membershipRole,
slug: profile.slug,
};
})) ?? [];
return (
<Shell
@@ -1007,13 +1687,30 @@ const EventTypesPage: React.FC & {
heading={t("event_types_page_title")}
hideHeadingOnMobile
subtitle={t("event_types_page_subtitle")}
beforeCTAactions={<Actions showDivider={profileOptions.length > 0} />}
CTA={<CTA profileOptions={profileOptions} isOrganization={!!user?.organizationId} />}>
beforeCTAactions={
isInfiniteScrollEnabled ? undefined : <Actions showDivider={profileOptions.length > 0} />
}
CTA={
<CTA
isInfiniteScrollEnabled={isInfiniteScrollEnabled}
profileOptions={profileOptions}
isOrganization={!!user?.organizationId}
/>
}>
<HeadSeo
title="Event Types"
description="Create events to share for people to book on your calendar."
/>
<Main data={data} status={status} errorMessage={error?.message} filters={filters} />
{isInfiniteScrollEnabled ? (
<InfiniteScrollMain
profiles={getUserEventGroupsData?.profiles}
eventTypeGroups={getUserEventGroupsData?.eventTypeGroups}
status={getUserEventGroupsStatus}
errorMessage={getUserEventGroupsStatusError?.message}
/>
) : (
<Main data={data} status={status} errorMessage={error?.message} filters={filters} />
)}
</Shell>
);
};
@@ -70,7 +70,7 @@ const querySchema = z.object({
export default function CreateEventTypeDialog({
profileOptions,
isOrganization,
isInfiniteScrollEnabled,
}: {
profileOptions: {
teamId: number | null | undefined;
@@ -78,7 +78,7 @@ export default function CreateEventTypeDialog({
image: string | undefined;
membershipRole: MembershipRole | null | undefined;
}[];
isOrganization: boolean;
isInfiniteScrollEnabled: boolean;
}) {
const utils = trpc.useUtils();
const { t } = useLocale();
@@ -118,8 +118,18 @@ export default function CreateEventTypeDialog({
const createMutation = trpc.viewer.eventTypes.create.useMutation({
onSuccess: async ({ eventType }) => {
await utils.viewer.eventTypes.getByViewer.invalidate();
await router.replace(`/event-types/${eventType.id}${teamId ? "?tabName=team" : ""}`);
if (isInfiniteScrollEnabled) {
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
await utils.viewer.eventTypes.getEventTypesFromGroup.invalidate({
limit: 10,
group: { teamId: eventType?.teamId, parentId: eventType?.parentId },
});
} else {
await utils.viewer.eventTypes.getByViewer.invalidate();
}
showToast(
t("event_type_created_successfully", {
eventTypeTitle: eventType.title,
@@ -32,9 +32,13 @@ const querySchema = z.object({
id: z.coerce.number(),
length: z.coerce.number(),
pageSlug: z.string(),
teamId: z.coerce.number().optional().nullable(),
parentId: z.coerce.number().optional().nullable(),
});
const DuplicateDialog = () => {
const DuplicateDialog = ({ isInfiniteScrollEnabled }: { isInfiniteScrollEnabled?: boolean }) => {
const utils = trpc.useUtils();
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const router = useRouter();
@@ -69,6 +73,17 @@ const DuplicateDialog = () => {
const duplicateMutation = trpc.viewer.eventTypes.duplicate.useMutation({
onSuccess: async ({ eventType }) => {
await router.replace(`/event-types/${eventType.id}`);
if (isInfiniteScrollEnabled) {
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
await utils.viewer.eventTypes.getEventTypesFromGroup.invalidate({
limit: 10,
group: { teamId: eventType?.teamId, parentId: eventType?.parentId },
});
} else {
await utils.viewer.eventTypes.getByViewer.invalidate();
}
showToast(
t("event_type_created_successfully", {
eventTypeTitle: eventType.title,
+25 -13
View File
@@ -16,8 +16,8 @@ import { useMemo } from "react";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { isMac } from "@calcom/lib/isMac";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Icon, Tooltip } from "@calcom/ui";
type shortcutArrayType = {
@@ -36,18 +36,30 @@ const getApps = Object.values(appStoreMetadata).map(({ name, slug }) => ({
const useEventTypesAction = () => {
const router = useRouter();
const { data } = trpc.viewer.eventTypes.getByViewer.useQuery();
const eventTypeActions = data?.eventTypeGroups.reduce<Action[]>((acc: Action[], group: EventTypeGroup) => {
const item: Action[] = group.eventTypes.map((item) => ({
id: `event-type-${item.id}`,
name: item.title,
section: "event_types_page_title",
keywords: "event types",
perform: () => router.push(`/event-types/${item.id}`),
}));
acc.push(...item);
return acc;
}, []);
const { data } = trpc.viewer.eventTypes.getEventTypesFromGroup.useInfiniteQuery(
{
limit: 10,
group: { teamId: null, parentId: null },
},
{
refetchOnWindowFocus: false,
staleTime: 1 * 60 * 60 * 1000,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const eventTypeActions: Action[] =
data?.pages?.flatMap((page) => {
return (
page?.eventTypes?.map((item) => ({
id: `event-type-${item.id}`,
name: item.title,
section: "event_types_page_title",
keywords: "event types",
perform: () => router.push(`/event-types/${item.id}`),
})) ?? []
);
}) ?? [];
const actions = eventTypeActions?.length ? eventTypeActions : [];
+4
View File
@@ -104,6 +104,10 @@ export const ENABLE_PROFILE_SWITCHER = process.env.NEXT_PUBLIC_ENABLE_PROFILE_SW
export const ALLOWED_HOSTNAMES = JSON.parse(`[${process.env.ALLOWED_HOSTNAMES || ""}]`) as string[];
export const RESERVED_SUBDOMAINS = JSON.parse(`[${process.env.RESERVED_SUBDOMAINS || ""}]`) as string[];
export const ENABLE_INFINITE_EVENT_TYPES_FOR_ORG = JSON.parse(
`[${process.env.ENABLE_INFINITE_EVENT_TYPES_FOR_ORG || ""}]`
) as string[];
export const ORGANIZATION_MIN_SEATS = 30;
// Needed for emails in E2E
+101 -1
View File
@@ -3,8 +3,11 @@ import { Prisma } from "@prisma/client";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { Ensure } from "@calcom/types/utils";
import { TRPCError } from "@trpc/server";
import { safeStringify } from "../../safeStringify";
import { eventTypeSelect } from "../eventTypeSelect";
import { LookupTarget, ProfileRepository } from "./profile";
@@ -103,7 +106,14 @@ export class EventTypeRepository {
{
orderBy,
where = {},
}: { orderBy?: Prisma.EventTypeOrderByWithRelationInput[]; where?: Prisma.EventTypeWhereInput } = {}
cursor: cursorId,
limit,
}: {
orderBy?: Prisma.EventTypeOrderByWithRelationInput[];
where?: Prisma.EventTypeWhereInput;
cursor?: number | null;
limit?: number | null;
} = {}
) {
if (!upId) return [];
const lookupTarget = ProfileRepository.getLookupTarget(upId);
@@ -133,6 +143,9 @@ export class EventTypeRepository {
})
);
const cursor = cursorId ? { id: cursorId } : undefined;
const take = limit ? limit + 1 : undefined; // We take +1 as it'll be used for the next cursor
if (!profileId) {
// Lookup is by userId
return await prisma.eventType.findMany({
@@ -141,6 +154,8 @@ export class EventTypeRepository {
...where,
},
select,
cursor,
take,
orderBy,
});
}
@@ -172,6 +187,8 @@ export class EventTypeRepository {
...where,
},
select,
cursor,
take,
orderBy,
});
} else {
@@ -192,11 +209,94 @@ export class EventTypeRepository {
...where,
},
select,
cursor,
take,
orderBy,
});
}
}
static async findTeamEventTypes({
teamId,
parentId,
userId,
limit,
cursor,
orderBy,
where = {},
}: {
teamId: number;
parentId?: number | null;
userId: number;
limit?: number | null;
cursor?: number | null;
orderBy?: Prisma.EventTypeOrderByWithRelationInput[];
where?: Prisma.EventTypeWhereInput;
}) {
const userSelect = Prisma.validator<Prisma.UserSelect>()({
name: true,
avatarUrl: true,
username: true,
id: true,
});
const select = {
...eventTypeSelect,
hashedLink: true,
users: { select: userSelect, take: 5 },
children: {
include: {
users: { select: userSelect, take: 5 },
},
},
hosts: {
include: {
user: { select: userSelect },
},
take: 5,
},
};
const teamMembership = await prisma.membership.findFirst({
where: {
OR: [
{
teamId,
userId,
accepted: true,
},
{
team: {
parent: {
...(parentId ? { id: parentId } : {}),
members: {
some: {
userId,
accepted: true,
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
},
},
},
},
},
],
},
});
if (!teamMembership) throw new TRPCError({ code: "UNAUTHORIZED" });
return await prisma.eventType.findMany({
where: {
teamId,
...where,
},
select,
cursor: cursor ? { id: cursor } : undefined,
take: limit ? limit + 1 : undefined, // We take +1 as itll be used for the next cursor
orderBy,
});
}
static async findAllByUserId({ userId }: { userId: number }) {
return await prisma.eventType.findMany({
where: {
@@ -131,4 +131,48 @@ export class MembershipRepository {
},
});
}
static async findAllByUpIdIncludeTeam(
{ upId }: { upId: string },
{ where }: { where?: Prisma.MembershipWhereInput } = {}
) {
const lookupTarget = ProfileRepository.getLookupTarget(upId);
let prismaWhere;
if (lookupTarget.type === LookupTarget.Profile) {
/**
* TODO: When we add profileId to membership, we lookup by profileId
* If the profile is movedFromUser, we lookup all memberships without profileId as well.
*/
const profile = await ProfileRepository.findById(lookupTarget.id);
if (!profile) {
return [];
}
prismaWhere = {
userId: profile.user.id,
...where,
};
} else {
prismaWhere = {
userId: lookupTarget.id,
...where,
};
}
return await prisma.membership.findMany({
where: prismaWhere,
include: {
team: {
include: {
// could slow down the query
// members: {
// select: membershipSelect,
// },
parent: {
select: teamParentSelect,
},
},
},
},
});
}
}
@@ -7,13 +7,15 @@ import { router } from "../../../trpc";
import { ZCreateInputSchema } from "./create.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZDuplicateInputSchema } from "./duplicate.schema";
import { ZEventTypeInputSchema } from "./getByViewer.schema";
import { ZEventTypeInputSchema, ZGetEventTypesFromGroupSchema } from "./getByViewer.schema";
import { get } from "./procedures/get";
import { ZUpdateInputSchema } from "./update.schema";
import { eventOwnerProcedure } from "./util";
type BookingsRouterHandlerCache = {
getByViewer?: typeof import("./getByViewer.handler").getByViewerHandler;
getUserEventGroups?: typeof import("./getUserEventGroups.handler").getUserEventGroups;
getEventTypesFromGroup?: typeof import("./getEventTypesFromGroup.handler").getEventTypesFromGroup;
list?: typeof import("./list.handler").listHandler;
listWithTeam?: typeof import("./listWithTeam.handler").listWithTeamHandler;
create?: typeof import("./create.handler").createHandler;
@@ -52,6 +54,55 @@ export const eventTypesRouter = router({
return result;
}),
getUserEventGroups: authedProcedure.input(ZEventTypeInputSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.getUserEventGroups) {
UNSTABLE_HANDLER_CACHE.getUserEventGroups = await import("./getUserEventGroups.handler").then(
(mod) => mod.getUserEventGroups
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getUserEventGroups) {
throw new Error("Failed to load handler");
}
const timer = logP(`getUserEventGroups(${ctx.user.id})`);
const result = await UNSTABLE_HANDLER_CACHE.getUserEventGroups({
ctx,
input,
});
timer();
return result;
}),
getEventTypesFromGroup: authedProcedure
.input(ZGetEventTypesFromGroupSchema)
.query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.getEventTypesFromGroup) {
UNSTABLE_HANDLER_CACHE.getEventTypesFromGroup = await import("./getEventTypesFromGroup.handler").then(
(mod) => mod.getEventTypesFromGroup
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getEventTypesFromGroup) {
throw new Error("Failed to load handler");
}
const timer = logP(`getEventTypesFromGroup(${ctx.user.id})`);
const result = await UNSTABLE_HANDLER_CACHE.getEventTypesFromGroup({
ctx,
input,
});
timer();
return result;
}),
list: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.list) {
@@ -17,3 +17,13 @@ export const ZEventTypeInputSchema = z
.nullish();
export type TEventTypeInputSchema = z.infer<typeof ZEventTypeInputSchema>;
export const ZGetEventTypesFromGroupSchema = z.object({
filters: filterQuerySchemaStrict.optional(),
forRoutingForms: z.boolean().optional(),
cursor: z.number().nullish(),
limit: z.number().default(10),
group: z.object({ teamId: z.number().nullish(), parentId: z.number().nullish() }),
});
export type TGetEventTypesFromGroupSchema = z.infer<typeof ZGetEventTypesFromGroupSchema>;
@@ -0,0 +1,125 @@
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import type { PrismaClient } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "../../../trpc";
import type { TGetEventTypesFromGroupSchema } from "./getByViewer.schema";
import { mapEventType } from "./util";
type GetByViewerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TGetEventTypesFromGroupSchema;
};
type EventType = Awaited<ReturnType<typeof EventTypeRepository.findAllByUpId>>[number];
export const getEventTypesFromGroup = async ({ ctx, input }: GetByViewerOptions) => {
await checkRateLimitAndThrowError({
identifier: `eventTypes:getEventTypesFromGroup:${ctx.user.id}`,
rateLimitingType: "common",
});
const userProfile = ctx.user.profile;
const { group, limit, cursor, filters } = input;
const { teamId, parentId } = group;
const isFilterSet = (filters && hasFilter(filters)) || !!teamId;
const isUpIdInFilter = filters?.upIds?.includes(userProfile.upId);
const shouldListUserEvents =
!isFilterSet || isUpIdInFilter || (isFilterSet && filters?.upIds && !isUpIdInFilter);
const eventTypes: EventType[] = [];
if (shouldListUserEvents || !teamId) {
const userEventTypes =
(await EventTypeRepository.findAllByUpId(
{
upId: userProfile.upId,
userId: ctx.user.id,
},
{
where: {
teamId: null,
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
limit,
cursor,
}
)) ?? [];
eventTypes.push(...userEventTypes);
}
if (teamId) {
const teamEventTypes =
(await EventTypeRepository.findTeamEventTypes({
teamId,
parentId,
userId: ctx.user.id,
limit,
cursor,
where: {
...(isFilterSet && !!filters?.schedulingTypes
? {
schedulingType: { in: filters.schedulingTypes },
}
: null),
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
})) ?? [];
eventTypes.push(...teamEventTypes);
}
let nextCursor: typeof cursor | undefined = undefined;
if (eventTypes && eventTypes.length > limit) {
const nextItem = eventTypes.pop();
nextCursor = nextItem?.id;
}
const mappedEventTypes = await Promise.all(eventTypes.map(mapEventType));
let filteredEventTypes = mappedEventTypes.filter((eventType) => {
const isAChildEvent = eventType.parentId;
if (!isAChildEvent) {
return true;
}
// A child event only has one user
const childEventAssignee = eventType.users[0];
if (!childEventAssignee || childEventAssignee.id != ctx.user.id) {
return false;
}
return true;
});
if (shouldListUserEvents || !teamId) {
filteredEventTypes = filteredEventTypes.filter(
(evType) => evType.schedulingType !== SchedulingType.MANAGED
);
}
return {
eventTypes: filteredEventTypes || [],
nextCursor,
};
};
@@ -0,0 +1,202 @@
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
// import { getEventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByViewer";
import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TEventTypeInputSchema } from "./getByViewer.schema";
type GetByViewerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TEventTypeInputSchema;
};
export const getUserEventGroups = async ({ ctx, input }: GetByViewerOptions) => {
await checkRateLimitAndThrowError({
identifier: `eventTypes:getUserProfiles:${ctx.user.id}`,
rateLimitingType: "common",
});
const user = ctx.user;
const filters = input?.filters;
const forRoutingForms = input?.forRoutingForms;
const userProfile = user.profile;
const profile = await ProfileRepository.findByUpId(userProfile.upId);
const parentOrgHasLockedEventTypes =
profile?.organization?.organizationSettings?.lockEventTypeCreationForUsers;
const isFilterSet = filters && hasFilter(filters);
const isUpIdInFilter = filters?.upIds?.includes(userProfile.upId);
let shouldListUserEvents = !isFilterSet || isUpIdInFilter;
if (isFilterSet && filters?.upIds && !isUpIdInFilter) {
shouldListUserEvents = true;
}
const profileMemberships = await MembershipRepository.findAllByUpIdIncludeTeam(
{
upId: userProfile.upId,
},
{
where: {
accepted: true,
},
}
);
if (!profile) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
const memberships = profileMemberships.map((membership) => ({
...membership,
team: {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
},
}));
const teamMemberships = profileMemberships.map((membership) => ({
teamId: membership.team.id,
membershipRole: membership.role,
}));
type EventTypeGroup = {
teamId?: number | null;
parentId?: number | null;
bookerUrl: string;
membershipRole?: MembershipRole | null;
profile: {
slug: (typeof profile)["username"] | null;
name: (typeof profile)["name"];
image: string;
eventTypesLockedByOrg?: boolean;
};
metadata: {
membershipCount: number;
readOnly: boolean;
};
};
let eventTypeGroups: EventTypeGroup[] = [];
if (shouldListUserEvents) {
const bookerUrl = await getBookerBaseUrl(profile.organizationId ?? null);
eventTypeGroups.push({
teamId: null,
bookerUrl,
membershipRole: null,
profile: {
slug: profile.username,
name: profile.name,
image: getUserAvatarUrl({
avatarUrl: profile.avatarUrl,
}),
eventTypesLockedByOrg: parentOrgHasLockedEventTypes,
},
metadata: {
membershipCount: 1,
readOnly: false,
},
});
}
eventTypeGroups = ([] as EventTypeGroup[]).concat(
eventTypeGroups,
await Promise.all(
memberships
.filter((mmship) => {
if (mmship.team.isOrganization) {
return false;
} else {
if (!filters || !hasFilter(filters)) {
return true;
}
return filters?.teamIds?.includes(mmship?.team?.id || 0) ?? false;
}
})
.map(async (membership) => {
const orgMembership = teamMemberships.find(
(teamM) => teamM.teamId === membership.team.parentId
)?.membershipRole;
const team = {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
};
let slug;
if (forRoutingForms) {
// For Routing form we want to ensure that after migration of team to an org, the URL remains same for the team
// Once we solve this https://github.com/calcom/cal.com/issues/12399, we can remove this conditional change in slug
slug = `team/${team.slug}`;
} else {
// In an Org, a team can be accessed without /team prefix as well as with /team prefix
slug = team.slug ? (!team.parentId ? `team/${team.slug}` : `${team.slug}`) : null;
}
// const eventTypes = await Promise.all(team.eventTypes.map(mapEventType));
const teamParentMetadata = team.parent ? teamMetadataSchema.parse(team.parent.metadata) : null;
return {
teamId: team.id,
parentId: team.parentId,
bookerUrl: getBookerBaseUrlSync(team.parent?.slug ?? teamParentMetadata?.requestedSlug ?? null),
membershipRole:
orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: membership.role,
profile: {
image: team.parent
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
: getPlaceholderAvatar(team.logoUrl, team.name),
name: team.name,
slug,
},
metadata: {
membershipCount: 0,
readOnly:
membership.role ===
(team.parentId
? orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: MembershipRole.MEMBER
: MembershipRole.MEMBER),
},
};
})
)
);
const denormalizedPayload = {
eventTypeGroups,
// so we can show a dropdown when the user has teams
profiles: eventTypeGroups.map((group) => ({
...group.profile,
...group.metadata,
teamId: group.teamId,
membershipRole: group.membershipRole,
})),
};
return denormalizedPayload;
};
export function compareMembership(mship1: MembershipRole, mship2: MembershipRole) {
const mshipToNumber = (mship: MembershipRole) =>
Object.keys(MembershipRole).findIndex((mmship) => mmship === mship);
return mshipToNumber(mship1) > mshipToNumber(mship2);
}
@@ -1,13 +1,19 @@
import { z } from "zod";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { MembershipRole, PeriodType } from "@calcom/prisma/enums";
import type { CustomInputSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import authedProcedure from "../../../procedures/authedProcedure";
import type { EventTypeUpdateInput } from "./types";
type EventType = Awaited<ReturnType<typeof EventTypeRepository.findAllByUpId>>[number];
export const eventOwnerProcedure = authedProcedure
.input(
z.object({
@@ -145,3 +151,30 @@ export function ensureUniqueBookingFields(fields: z.infer<typeof EventTypeUpdate
return discoveredFields;
}, {} as Record<string, true>);
}
export const mapEventType = async (eventType: EventType) => ({
...eventType,
safeDescription: eventType?.description ? markdownToSafeHTML(eventType.description) : undefined,
users: await Promise.all(
(!!eventType?.hosts?.length ? eventType?.hosts.map((host) => host.user) : eventType.users).map(
async (u) =>
await UserRepository.enrichUserWithItsProfile({
user: u,
})
)
),
metadata: eventType.metadata ? EventTypeMetaDataSchema.parse(eventType.metadata) : null,
children: await Promise.all(
(eventType.children || []).map(async (c) => ({
...c,
users: await Promise.all(
c.users.map(
async (u) =>
await UserRepository.enrichUserWithItsProfile({
user: u,
})
)
),
}))
),
});
@@ -12,6 +12,7 @@ export type AvatarGroupProps = {
}[];
className?: string;
truncateAfter?: number;
hideTruncatedAvatarsCount?: boolean;
};
export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
@@ -52,7 +53,7 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
" text-inverted m-auto flex h-full w-full items-center justify-center text-center",
props.size === "sm" ? "text-[12px]" : "text-2xl"
)}>
+{numTruncatedAvatars}
+{props.hideTruncatedAvatarsCount ? null : numTruncatedAvatars}
</span>
</li>
)}
@@ -7,6 +7,7 @@ import { AvatarGroup } from "@calcom/ui";
type UserAvatarProps = Omit<React.ComponentProps<typeof AvatarGroup>, "items"> & {
users: (Pick<User, "name" | "username" | "avatarUrl"> & {
profile: Omit<UserProfile, "upId">;
hideTruncatedAvatarsCount?: boolean;
})[];
};
export function UserAvatarGroup(props: UserAvatarProps) {
@@ -22,6 +23,7 @@ export function UserAvatarGroup(props: UserAvatarProps) {
alt: user.name || "",
title: user.name || "",
image: getUserAvatarUrl(user),
hideTruncatedAvatarsCount: user.hideTruncatedAvatarsCount,
}))}
/>
);
+1
View File
@@ -285,6 +285,7 @@
"EMAIL_SERVER_PORT",
"EMAIL_SERVER_USER",
"EMAIL_SERVER",
"ENABLE_INFINITE_EVENT_TYPES_FOR_ORG",
"EXCHANGE_DEFAULT_EWS_URL",
"FORMBRICKS_FEEDBACK_SURVEY_ID",
"AVATARAPI_USERNAME",