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:
co-authored by
Udit Takkar
Peer Richelsen
Hariom Balhara
parent
583661a15d
commit
45db5c0d5e
@@ -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 } };
|
||||
};
|
||||
|
||||
@@ -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'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,
|
||||
|
||||
@@ -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 : [];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user