diff --git a/apps/web/app/WithAppDirSsr.tsx b/apps/web/app/WithAppDirSsr.tsx index ae253732da..5fe8492c85 100644 --- a/apps/web/app/WithAppDirSsr.tsx +++ b/apps/web/app/WithAppDirSsr.tsx @@ -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, + }), }; }; diff --git a/apps/web/lib/event-types/getServerSideProps.tsx b/apps/web/lib/event-types/getServerSideProps.tsx index cbc7bab049..e8996f44d0 100644 --- a/apps/web/lib/event-types/getServerSideProps.tsx +++ b/apps/web/lib/event-types/getServerSideProps.tsx @@ -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 } }; }; diff --git a/apps/web/lib/hooks/useInViewObserver.ts b/apps/web/lib/hooks/useInViewObserver.ts index 578fca644d..7396f2be46 100644 --- a/apps/web/lib/hooks/useInViewObserver.ts +++ b/apps/web/lib/hooks/useInViewObserver.ts @@ -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(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); diff --git a/apps/web/modules/event-types/views/event-types-listing-view.tsx b/apps/web/modules/event-types/views/event-types-listing-view.tsx index 13bd2620dd..8d2fa94c5b 100644 --- a/apps/web/modules/event-types/views/event-types-listing-view.tsx +++ b/apps/web/modules/event-types/views/event-types-listing-view.tsx @@ -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 & { 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 = (props) => { ); }; +const InfiniteMobileTeamsTab: FC = (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 ( +
+ {!!activeEventTypeGroup && ( + + )} +
+ +
+
+ ); +}; + 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(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0); + const [deleteDialogTypeSchedulingType, setDeleteDialogSchedulingType] = useState( + 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 ? ( + + ) : !group.profile.eventTypesLockedByOrg ? ( + + ) : ( + <> + ); + } + + 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 ( +
+
    + {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 ( +
  • +
    +
    + {!(firstItem && firstItem.id === type.id) && ( + moveEventType(LIMIT * pageIdx + index, -1)} + arrowDirection="up" + /> + )} + + {!(lastItem && lastItem.id === type.id) && ( + moveEventType(LIMIT * pageIdx + index, 1)} + arrowDirection="down" + /> + )} + +
    +
    + {!!type.teamId && !isManagedEventType && ( + + )} + {isManagedEventType && type?.children && type.children?.length > 0 && ( + ch.users) ?? []} + /> + )} +
    + {!isManagedEventType && ( + <> + {type.hidden && {t("hidden")}} + +
    + { + setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden }); + }} + /> +
    +
    + + )} + + + {!isManagedEventType && ( + <> + +
    +
    +
    +
    +
    + + +
    +
    +
  • + ); + }); + })} +
+ + + { + e.preventDefault(); + deleteEventTypeHandler(deleteDialogTypeId); + }}> +

+ , ul:

    }}> +
      +
    • Members assigned to this event type will also have their event types deleted.
    • +
    • + Anyone who they've shared their link with will no longer be able to book using it. +
    • +
    + +

    + +
+
+ ); +}; + 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={() => ( - + )} /> ); @@ -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 ; + } + + if (!eventTypeGroups || !profiles || status === "pending") { + return ; + } + + 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 && ( + <> + + + + )} + {eventTypeGroups.length === 0 && } + + {searchParams?.get("dialog") === "duplicate" && } + + ); +}; + +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 ( 0} />} - CTA={}> + beforeCTAactions={ + isInfiniteScrollEnabled ? undefined : 0} /> + } + CTA={ + + }> -
+ {isInfiniteScrollEnabled ? ( + + ) : ( +
+ )} ); }; diff --git a/packages/features/eventtypes/components/CreateEventTypeDialog.tsx b/packages/features/eventtypes/components/CreateEventTypeDialog.tsx index 4bc0c79f0a..2453f0a901 100644 --- a/packages/features/eventtypes/components/CreateEventTypeDialog.tsx +++ b/packages/features/eventtypes/components/CreateEventTypeDialog.tsx @@ -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, diff --git a/packages/features/eventtypes/components/DuplicateDialog.tsx b/packages/features/eventtypes/components/DuplicateDialog.tsx index 2ad43f5969..e3e9c06af4 100644 --- a/packages/features/eventtypes/components/DuplicateDialog.tsx +++ b/packages/features/eventtypes/components/DuplicateDialog.tsx @@ -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, diff --git a/packages/features/kbar/Kbar.tsx b/packages/features/kbar/Kbar.tsx index 6203d5b38d..bd5d0ecb05 100644 --- a/packages/features/kbar/Kbar.tsx +++ b/packages/features/kbar/Kbar.tsx @@ -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((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 : []; diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 7ce451c7f3..88d3ffbddc 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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 diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index c6ab13d513..dfbe64f731 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -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()({ + 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: { diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index f054d254da..644f6d6fbb 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -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, + }, + }, + }, + }, + }); + } } diff --git a/packages/trpc/server/routers/viewer/eventTypes/_router.ts b/packages/trpc/server/routers/viewer/eventTypes/_router.ts index f1eeaf0546..cffc893837 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/_router.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/_router.ts @@ -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) { diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts index 68bcadd1d2..fb48f9a0d5 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts @@ -17,3 +17,13 @@ export const ZEventTypeInputSchema = z .nullish(); export type TEventTypeInputSchema = z.infer; + +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; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getEventTypesFromGroup.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getEventTypesFromGroup.handler.ts new file mode 100644 index 0000000000..c97ac40cda --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getEventTypesFromGroup.handler.ts @@ -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; + prisma: PrismaClient; + }; + input: TGetEventTypesFromGroupSchema; +}; + +type EventType = Awaited>[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, + }; +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getUserEventGroups.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getUserEventGroups.handler.ts new file mode 100644 index 0000000000..08439259d3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getUserEventGroups.handler.ts @@ -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; + 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); +} diff --git a/packages/trpc/server/routers/viewer/eventTypes/util.ts b/packages/trpc/server/routers/viewer/eventTypes/util.ts index 5e4c179d3b..1418fe952c 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/util.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/util.ts @@ -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>[number]; + export const eventOwnerProcedure = authedProcedure .input( z.object({ @@ -145,3 +151,30 @@ export function ensureUniqueBookingFields(fields: z.infer); } + +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, + }) + ) + ), + })) + ), +}); diff --git a/packages/ui/components/avatar/AvatarGroup.tsx b/packages/ui/components/avatar/AvatarGroup.tsx index be09cff71e..68a9018c1d 100644 --- a/packages/ui/components/avatar/AvatarGroup.tsx +++ b/packages/ui/components/avatar/AvatarGroup.tsx @@ -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} )} diff --git a/packages/ui/components/avatar/UserAvatarGroup.tsx b/packages/ui/components/avatar/UserAvatarGroup.tsx index 9378e9d687..53e0024541 100644 --- a/packages/ui/components/avatar/UserAvatarGroup.tsx +++ b/packages/ui/components/avatar/UserAvatarGroup.tsx @@ -7,6 +7,7 @@ import { AvatarGroup } from "@calcom/ui"; type UserAvatarProps = Omit, "items"> & { users: (Pick & { profile: Omit; + 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, }))} /> ); diff --git a/turbo.json b/turbo.json index 996961bb82..2727bf9e9d 100644 --- a/turbo.json +++ b/turbo.json @@ -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",