refactor: enable infinite scrolling for all (#16463)

* refactor: enable infinite scrolling for all

* chore: remove unused code

* fix: tests

* chore: type err

* chore: update email embed test

* chore: update tests

* chore: update tests

* chore: fix horizontal tabitem

* chore

* fix: conflicts

* chore: conflict

* fix: E2E tests

* chore: remove idle

* fix test

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
This commit is contained in:
Udit Takkar
2024-09-30 10:44:04 +01:00
committed by GitHub
co-authored by Syed Ali Shahbaz
parent c834b927b6
commit ac300ff670
14 changed files with 67 additions and 884 deletions
-3
View File
@@ -21,8 +21,5 @@ export const withAppDirSsr =
...("trpcState" in props && {
dehydratedState: props.trpcState,
}),
...("isInfiniteScrollEnabled" in props && {
isInfiniteScrollEnabled: props.isInfiniteScrollEnabled,
}),
};
};
@@ -1,7 +1,6 @@
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";
@@ -18,9 +17,5 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
};
}
const isInfiniteScrollEnabled = session.user?.org?.slug
? ENABLE_INFINITE_EVENT_TYPES_FOR_ORG.includes(session.user.org.slug)
: false;
return { props: { trpcState: ssr.dehydrate(), isInfiniteScrollEnabled } };
return { props: { trpcState: ssr.dehydrate() } };
};
@@ -14,10 +14,7 @@ import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/emb
import { EventTypeDescription } from "@calcom/features/eventtypes/components";
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
import SkeletonLoader, {
InfiniteSkeletonLoader,
} from "@calcom/features/eventtypes/components/SkeletonLoader";
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import Shell from "@calcom/features/shell/Shell";
import { parseEventTypeColor } from "@calcom/lib";
@@ -25,7 +22,6 @@ import { APP_NAME } from "@calcom/lib/constants";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
@@ -38,7 +34,6 @@ import { trpc, TRPCClientError } from "@calcom/trpc/react";
import type { UserProfile } from "@calcom/types/UserProfile";
import {
Alert,
Avatar,
Badge,
Button,
ButtonGroup,
@@ -62,7 +57,6 @@ import {
Tooltip,
ArrowButton,
UserAvatarGroup,
Icon,
} from "@calcom/ui";
import type { AppProps } from "@lib/app-providers";
@@ -76,8 +70,6 @@ type InfiniteEventTypeGroup = GetUserEventGroupsResponse["eventTypeGroups"][numb
type InfiniteEventType = GetEventTypesFromGroupsResponse["eventTypes"][number];
type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
type GetByViewerResponse = RouterOutputs["viewer"]["eventTypes"]["getByViewer"] | undefined;
type EventTypeGroup = EventTypeGroups[number];
type EventType = EventTypeGroup["eventTypes"][number];
@@ -89,19 +81,8 @@ type DeNormalizedEventType = Omit<EventType, "userIds"> & {
})[];
};
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;
@@ -111,56 +92,15 @@ interface InfiniteEventTypeListProps {
isPending?: boolean;
}
interface EventTypeListProps {
group: DeNormalizedEventTypeGroup;
groupIndex: number;
readOnly: boolean;
bookerUrl: string | null;
types: DeNormalizedEventType[];
lockedByOrg?: boolean;
}
interface InfiniteMobileTeamsTabProps {
interface InfiniteTeamsTabProps {
activeEventTypeGroup: InfiniteEventTypeGroup;
}
interface MobileTeamsTabProps {
eventTypeGroups: DeNormalizedEventTypeGroup[];
}
const querySchema = z.object({
teamId: z.nullable(z.coerce.number()).optional().default(null),
});
const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
const { eventTypeGroups } = props;
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 { data } = useTypedQuery(querySchema);
const events = eventTypeGroups.filter((item) => item.teamId === data.teamId);
return (
<div>
<HorizontalTabs tabs={tabs} />
{events.length > 0 ? (
<EventTypeList
types={events[0].eventTypes}
group={events[0]}
groupIndex={0}
bookerUrl={events[0].bookerUrl}
readOnly={events[0].metadata.readOnly}
/>
) : (
<CreateFirstEventTypeView slug={eventTypeGroups[0].profile.slug ?? ""} />
)}
</div>
);
};
const InfiniteMobileTeamsTab: FC<InfiniteMobileTeamsTabProps> = (props) => {
const InfiniteTeamsTab: FC<InfiniteTeamsTabProps> = (props) => {
const { activeEventTypeGroup } = props;
const { t } = useLocale();
@@ -292,510 +232,6 @@ const Item = ({
const MemoizedItem = memo(Item);
export const EventTypeList = ({
group,
groupIndex,
readOnly,
types,
bookerUrl,
lockedByOrg,
}: EventTypeListProps): 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);
await utils.viewer.eventTypes.getByViewer.cancel();
await utils.viewer.eventTypes.invalidate();
},
onSettled: () => {
utils.viewer.eventTypes.invalidate();
},
});
const setHiddenMutation = trpc.viewer.eventTypes.update.useMutation({
onMutate: async ({ id }) => {
await utils.viewer.eventTypes.getByViewer.cancel();
const previousValue = utils.viewer.eventTypes.getByViewer.getData();
if (previousValue) {
const newList = [...types.map(normalizeEventType)];
const itemIndex = newList.findIndex((item) => item.id === id);
if (itemIndex !== -1 && newList[itemIndex]) {
newList[itemIndex].hidden = !newList[itemIndex].hidden;
}
utils.viewer.eventTypes.getByViewer.setData(undefined, {
...previousValue,
eventTypeGroups: [
...previousValue.eventTypeGroups.slice(0, groupIndex),
{ ...group, eventTypes: newList },
...previousValue.eventTypeGroups.slice(groupIndex + 1),
],
});
}
return { previousValue };
},
onError: async (err, _, context) => {
if (context?.previousValue) {
utils.viewer.eventTypes.getByViewer.setData(undefined, context.previousValue);
}
console.error(err.message);
},
onSettled: () => {
// REVIEW: Should we invalidate the entire router or just the `getByViewer` query?
utils.viewer.eventTypes.invalidate();
},
});
async function moveEventType(index: number, increment: 1 | -1) {
const newList = [...types.map(normalizeEventType)];
const type = types[index];
const tmp = types[index + increment];
if (tmp) {
newList[index] = normalizeEventType(tmp);
newList[index + increment] = normalizeEventType(type);
}
await utils.viewer.eventTypes.getByViewer.cancel();
const previousValue = utils.viewer.eventTypes.getByViewer.getData();
if (previousValue) {
utils.viewer.eventTypes.getByViewer.setData(undefined, {
...previousValue,
eventTypeGroups: [
...previousValue.eventTypeGroups.slice(0, groupIndex),
{ ...group, eventTypes: newList },
...previousValue.eventTypeGroups.slice(groupIndex + 1),
],
});
}
mutation.mutate({
ids: newList.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: DeNormalizedEventType, group: DeNormalizedEventTypeGroup) => {
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);
setParamsIfDefined("teamId", eventType.teamId);
setParamsIfDefined("parentId", eventType.parentId);
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.getByViewer.cancel();
const previousValue = utils.viewer.eventTypes.getByViewer.getData();
if (previousValue) {
const newList = types.filter((item) => item.id !== id).map(normalizeEventType);
utils.viewer.eventTypes.getByViewer.setData(undefined, {
...previousValue,
eventTypeGroups: [
...previousValue.eventTypeGroups.slice(0, groupIndex),
{ ...group, eventTypes: newList },
...previousValue.eventTypeGroups.slice(groupIndex + 1),
],
});
}
return { previousValue };
},
onError: (err, _, context) => {
if (context?.previousValue) {
utils.viewer.eventTypes.getByViewer.setData(undefined, 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");
}
},
onSettled: () => {
// REVIEW: Should we invalidate the entire router or just the `getByViewer` query?
utils.viewer.eventTypes.invalidate();
},
});
const [isNativeShare, setNativeShare] = useState(true);
useEffect(() => {
if (!navigator.share) {
setNativeShare(false);
}
}, []);
if (!types.length) {
return group.teamId ? (
<EmptyEventTypeList group={group} />
) : !group.profile.eventTypesLockedByOrg ? (
<CreateFirstEventTypeView slug={group.profile.slug ?? ""} />
) : (
<></>
);
}
const firstItem = types[0];
const lastItem = types[types.length - 1];
const isManagedEventPrefix = () => {
return deleteDialogTypeSchedulingType === SchedulingType.MANAGED ? "_managed" : "";
};
return (
<div className="bg-default border-subtle mb-16 flex overflow-hidden rounded-md border">
<ul ref={parent} className="divide-subtle !static w-full divide-y" data-testid="event-types">
{types.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 py-4 pl-2 pr-4 sm:pl-3 sm:pr-6">
{!(firstItem && firstItem.id === type.id) && (
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
)}
{!(lastItem && lastItem.id === type.id) && (
<ArrowButton onClick={() => moveEventType(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}
users={type?.users ?? []}
/>
)}
{isManagedEventType && type?.children && type.children?.length > 0 && (
<UserAvatarGroup
className="relative right-3"
size="sm"
truncateAfter={4}
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 transition">
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
className="mt-2 inline cursor-pointer self-center pr-2 ">
{type.hidden ? t("show_eventtype_on_profile") : t("hide_from_profile")}
</Skeleton>
<Switch
id="hiddenSwitch"
name="Hidden"
checked={!type.hidden}
onCheckedChange={() => {
setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden });
}}
/>
</div>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
</div>
</div>
</li>
);
})}
</ul>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
variety="danger"
title={t(`delete${isManagedEventPrefix()}_event_type`)}
confirmBtnText={t(`confirm_delete_event_type`)}
loadingText={t(`confirm_delete_event_type`)}
isPending={deleteMutation.isPending}
onConfirm={(e) => {
e.preventDefault();
deleteEventTypeHandler(deleteDialogTypeId);
}}>
<p className="mt-5">
<Trans
i18nKey={`delete${isManagedEventPrefix()}_event_type_description`}
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
<ul>
<li>Members assigned to this event type will also have their event types deleted.</li>
<li>
Anyone who they&apos;ve shared their link with will no longer be able to book using it.
</li>
</ul>
</Trans>
</p>
</ConfirmationDialogContent>
</Dialog>
</div>
);
};
export const InfiniteEventTypeList = ({
group,
readOnly,
@@ -1339,66 +775,6 @@ export const InfiniteEventTypeList = ({
);
};
const EventTypeListHeading = ({
profile,
membershipCount,
teamId,
bookerUrl,
}: EventTypeListHeadingProps): JSX.Element => {
const { t } = useLocale();
const router = useRouter();
const publishTeamMutation = trpc.viewer.teams.publish.useMutation({
onSuccess(data) {
router.push(data.url);
},
onError: (error) => {
showToast(error.message, "error");
},
});
return (
<div className="mb-4 flex items-center space-x-2">
<Avatar
alt={profile.name || ""}
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
imageSrc={profile.image}
size="md"
className="mt-1 inline-flex justify-center"
/>
<div>
<Link
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
className="text-emphasis font-bold">
{profile.name || ""}
</Link>
{membershipCount && teamId && (
<span className="text-subtle relative -top-px me-2 ms-2 text-xs">
<Link href={`/settings/teams/${teamId}/members`}>
<Badge variant="gray">
<Icon name="users" className="-mt-px mr-1 inline h-3 w-3" />
{membershipCount}
</Badge>
</Link>
</span>
)}
{profile.slug && (
<Link href={`${bookerUrl}/${profile.slug}`} className="text-subtle block text-xs">
{`${bookerUrl.replace("https://", "").replace("http://", "")}/${profile.slug}`}
</Link>
)}
</div>
{!profile.slug && !!teamId && (
<button onClick={() => publishTeamMutation.mutate({ teamId })}>
<Badge variant="gray" className="-ml-2 mb-1">
{t("upgrade")}
</Badge>
</button>
)}
</div>
);
};
const CreateFirstEventTypeView = ({ slug }: { slug: string }) => {
const { t } = useLocale();
@@ -1420,7 +796,6 @@ const CreateFirstEventTypeView = ({ slug }: { slug: string }) => {
const CTA = ({
profileOptions,
isOrganization,
isInfiniteScrollEnabled,
}: {
profileOptions: {
teamId: number | null | undefined;
@@ -1430,7 +805,6 @@ const CTA = ({
slug: string | null;
}[];
isOrganization: boolean;
isInfiniteScrollEnabled: boolean;
}) => {
const { t } = useLocale();
@@ -1441,24 +815,11 @@ const CTA = ({
data-testid="new-event-type"
subtitle={t("create_event_on").toUpperCase()}
options={profileOptions}
createDialog={() => (
<CreateEventTypeDialog
isInfiniteScrollEnabled={isInfiniteScrollEnabled}
profileOptions={profileOptions}
/>
)}
createDialog={() => <CreateEventTypeDialog profileOptions={profileOptions} />}
/>
);
};
const Actions = (props: { showDivider: boolean }) => {
return (
<div className="hidden items-center md:flex">
<TeamsFilter useProfileFilter popoverTriggerClassNames="mb-0" showVerticalDivider={props.showDivider} />
</div>
);
};
const EmptyEventTypeList = ({ group }: { group: EventTypeGroup | InfiniteEventTypeGroup }) => {
const { t } = useLocale();
return (
@@ -1478,99 +839,6 @@ const EmptyEventTypeList = ({ group }: { group: EventTypeGroup | InfiniteEventTy
);
};
const Main = ({
status,
errorMessage,
data: rawData,
filters,
}: {
status: string;
data: GetByViewerResponse;
errorMessage?: string;
filters: ReturnType<typeof getTeamsFiltersFromQuery>;
}) => {
const isMobile = useMediaQuery("(max-width: 768px)");
const searchParams = useCompatSearchParams();
if (status === "error") {
return <Alert severity="error" title="Something went wrong" message={errorMessage} />;
}
if (!rawData || status === "pending") {
return <SkeletonLoader />;
}
const isFilteredByOnlyOneItem =
(filters?.teamIds?.length === 1 || filters?.userIds?.length === 1) &&
rawData.eventTypeGroups.length === 1;
const data = denormalizePayload(rawData);
return (
<>
{data.eventTypeGroups.length > 1 || isFilteredByOnlyOneItem ? (
<>
{isMobile ? (
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
) : (
data.eventTypeGroups.map((group, index: number) => {
const eventsLockedByOrg = group.profile.eventTypesLockedByOrg;
const userHasManagedOrHiddenEventTypes = group.eventTypes.find(
(event) => event.metadata?.managedEventConfig || event.hidden
);
if (eventsLockedByOrg && !userHasManagedOrHiddenEventTypes) return null;
return (
<div
className="mt-4 flex flex-col"
data-testid={`slug-${group.profile.slug}`}
key={group.profile.slug}>
{/* If the group is readonly and empty don't leave a floating header when the user cant see the create box due
to it being readonly for that user */}
{group.eventTypes.length === 0 && group.metadata.readOnly ? null : (
<EventTypeListHeading
profile={group.profile}
membershipCount={group.metadata.membershipCount}
teamId={group.teamId}
bookerUrl={group.bookerUrl}
/>
)}
{group.eventTypes.length ? (
<EventTypeList
types={group.eventTypes}
group={group}
bookerUrl={group.bookerUrl}
groupIndex={index}
readOnly={group.metadata.readOnly}
lockedByOrg={eventsLockedByOrg}
/>
) : group.teamId && !group.metadata.readOnly ? (
<EmptyEventTypeList group={group} />
) : !group.metadata.readOnly ? (
<CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />
) : null}
</div>
);
})
)}
</>
) : (
data.eventTypeGroups.length === 1 && (
<EventTypeList
types={data.eventTypeGroups[0].eventTypes}
group={data.eventTypeGroups[0]}
groupIndex={0}
bookerUrl={data.eventTypeGroups[0].bookerUrl}
readOnly={data.eventTypeGroups[0].metadata.readOnly}
/>
)
)}
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />}
<EventTypeEmbedDialog />
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog />}
</>
);
};
const InfiniteScrollMain = ({
status,
errorMessage,
@@ -1607,20 +875,20 @@ const InfiniteScrollMain = ({
{eventTypeGroups.length >= 1 && (
<>
<HorizontalTabs tabs={tabs} />
<InfiniteMobileTeamsTab activeEventTypeGroup={activeEventTypeGroup[0]} />
<InfiniteTeamsTab activeEventTypeGroup={activeEventTypeGroup[0]} />
</>
)}
{eventTypeGroups.length === 0 && <CreateFirstEventTypeView slug={profiles[0].slug ?? ""} />}
<EventTypeEmbedDialog />
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog isInfiniteScrollEnabled />}
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog />}
</>
);
};
const EventTypesPage: React.FC<{ isInfiniteScrollEnabled?: boolean }> & {
const EventTypesPage: React.FC & {
PageWrapper?: AppProps["Component"]["PageWrapper"];
getLayout?: AppProps["Component"]["getLayout"];
} = ({ isInfiniteScrollEnabled = false }) => {
} = () => {
const { t } = useLocale();
const searchParams = useCompatSearchParams();
const { open } = useIntercom();
@@ -1632,13 +900,6 @@ const EventTypesPage: React.FC<{ isInfiniteScrollEnabled?: boolean }> & {
const filters = getTeamsFiltersFromQuery(routerQuery);
const router = useRouter();
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,
@@ -1648,7 +909,6 @@ const EventTypesPage: React.FC<{ isInfiniteScrollEnabled?: boolean }> & {
refetchOnWindowFocus: false,
gcTime: 1 * 60 * 60 * 1000,
staleTime: 1 * 60 * 60 * 1000,
enabled: isInfiniteScrollEnabled,
});
useEffect(() => {
@@ -1672,31 +932,18 @@ const EventTypesPage: React.FC<{ isInfiniteScrollEnabled?: boolean }> & {
}, [orgBranding, user]);
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,
};
})) ?? [];
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
@@ -1707,63 +954,20 @@ const EventTypesPage: React.FC<{ isInfiniteScrollEnabled?: boolean }> & {
heading={t("event_types_page_title")}
hideHeadingOnMobile
subtitle={t("event_types_page_subtitle")}
beforeCTAactions={
isInfiniteScrollEnabled ? undefined : <Actions showDivider={profileOptions.length > 0} />
}
CTA={
<CTA
isInfiniteScrollEnabled={isInfiniteScrollEnabled}
profileOptions={profileOptions}
isOrganization={!!user?.organizationId}
/>
}>
CTA={<CTA profileOptions={profileOptions} isOrganization={!!user?.organizationId} />}>
<HeadSeo
title="Event Types"
description="Create events to share for people to book on your calendar."
/>
{isInfiniteScrollEnabled ? (
<InfiniteScrollMain
profiles={getUserEventGroupsData?.profiles}
eventTypeGroups={getUserEventGroupsData?.eventTypeGroups}
status={getUserEventGroupsStatus}
errorMessage={getUserEventGroupsStatusError?.message}
/>
) : (
<Main data={data} status={status} errorMessage={error?.message} filters={filters} />
)}
<InfiniteScrollMain
profiles={getUserEventGroupsData?.profiles}
eventTypeGroups={getUserEventGroupsData?.eventTypeGroups}
status={getUserEventGroupsStatus}
errorMessage={getUserEventGroupsStatusError?.message}
/>
</Shell>
);
};
export default EventTypesPage;
function normalizeEventType(eventType: DeNormalizedEventType): EventType {
return {
...eventType,
userIds: eventType.users.map((user) => user.id),
};
}
function denormalizePayload(data: NonNullable<GetByViewerResponse>) {
return {
...data,
eventTypeGroups: data.eventTypeGroups.map((eventTypeGroup) => {
return {
...eventTypeGroup,
eventTypes: eventTypeGroup.eventTypes.map((eventType) => {
const { userIds, ...rest } = eventType;
return {
...rest,
users: userIds.map((userId) => {
const user = data.allUsersAcrossAllEventTypes.get(userId);
if (!user) {
throw new Error(`User with id ${userId} not found in allUsersAcrossAllEventTypes`);
}
return user;
}),
};
}),
};
}),
};
}
@@ -42,7 +42,6 @@ export const CreateTeamEventType = () => {
SubmitButton={SubmitButton}
onSuccessMutation={onSuccessMutation}
onErrorMutation={onErrorMutation}
isInfiniteScrollEnabled={false}
/>
);
};
@@ -249,6 +249,7 @@ test.describe("Manage Booking Questions", () => {
},
select: {
id: true,
name: true,
},
});
@@ -256,7 +257,10 @@ test.describe("Manage Booking Questions", () => {
const webhookReceiver = await addWebhook(undefined, teamId);
await test.step("Go to First Team Event", async () => {
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
await page.getByTestId(`horizontal-tab-${team?.name}`).click();
await page.waitForLoadState("networkidle");
const $eventTypes = page.locator("[data-testid=event-types]").locator("li a");
const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
@@ -38,7 +38,7 @@ async function setupManagedEvent({
const memberUser = users.get().find((u) => u.name === teamMateName)!;
const { team } = await adminUser.getFirstTeamMembership();
const managedEvent = await adminUser.getFirstTeamEvent(team.id, SchedulingType.MANAGED);
return { adminUser, memberUser, managedEvent, teamMateName, teamEventTitle };
return { adminUser, memberUser, managedEvent, teamMateName, teamEventTitle, teamId: team.id };
}
/** Short hand to get elements by translation key */
@@ -129,7 +129,9 @@ test.describe("Managed Event Types", () => {
test("Provides discrete field lock/unlock state for admin", async ({ page, users }) => {
const { adminUser, teamEventTitle } = await setupManagedEvent({ users });
await adminUser.apiLogin();
await page.goto("/event-types");
const teamMembership = await adminUser.getFirstTeamMembership();
await page.goto(`/event-types?teamId=${teamMembership.team.id}`);
await page.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click();
await page.waitForURL("event-types/**");
@@ -169,7 +171,7 @@ test.describe("Managed Event Types", () => {
users,
browser,
}) => {
const { adminUser, memberUser, teamEventTitle } = await setupManagedEvent({
const { adminUser, memberUser, teamEventTitle, teamId } = await setupManagedEvent({
users,
unlockedFields: {
title: true,
@@ -185,7 +187,7 @@ test.describe("Managed Event Types", () => {
// We edit the managed event as original owner
const [adminContext, adminPage] = await adminUser.apiLoginOnNewBrowser(browser);
await adminPage.goto("/event-types");
await adminPage.goto(`/event-types?teamId=${teamId}`);
await adminPage.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click();
await adminPage.waitForURL("event-types/**");
await adminPage.locator('input[name="length"]').fill(`45`);
@@ -47,19 +47,28 @@ test.describe("user1NotMemberOfOrg1 is part of team1MemberOfOrg1", () => {
await user1NotMemberOfOrg1.apiLogin();
await page.goto("/event-types");
const userEventLinksSelector = `[data-testid=slug-${user1NotMemberOfOrg1.username}] [data-testid="preview-link-button"]`;
await page.waitForSelector(userEventLinksSelector);
await page.waitForSelector(`[data-testid="event-types"] [data-testid="preview-link-button"]`, {
timeout: 5000,
});
const userEventLinksLocators = await page
.locator(`[data-testid="event-types"] [data-testid="preview-link-button"]`)
.all();
// Get all the event links
const userEventLinksLocators = await page.locator(userEventLinksSelector).all();
expect(userEventLinksLocators.length).toBeGreaterThan(0);
for (const userEventLinkLocator of userEventLinksLocators) {
const href = await userEventLinkLocator.getAttribute("href");
expect(href).toContain(WEBAPP_URL);
}
await page.goto(`/event-types?teamId=${team1MemberOfOrg1.id}`);
await page.waitForSelector(`[data-testid="event-types"] [data-testid="preview-link-button"]`, {
timeout: 5000,
});
const teamEventLinksLocators = await page
.locator(`[data-testid=slug-${team1MemberOfOrg1.slug}] [data-testid="preview-link-button"]`)
.locator(`[data-testid="event-types"] [data-testid="preview-link-button"]`)
.all();
expect(teamEventLinksLocators.length).toBeGreaterThan(0);
+1 -1
View File
@@ -263,7 +263,7 @@ testBothFutureAndLegacyRoutes.describe("Teams - NonOrg", (routeVariant) => {
id: teamEventId,
} = await owner.getFirstTeamEvent(team.id);
await page.goto("/event-types");
await page.goto(`/event-types?teamId=${team.id}`);
await page.getByTestId(`event-type-options-${teamEventId}`).first().click();
await page.getByTestId("embed").click();
@@ -17,7 +17,6 @@ type props = {
SubmitButton: (isPending: boolean) => ReactNode;
onSuccessMutation: (eventType: EventType) => void;
onErrorMutation: (message: string) => void;
isInfiniteScrollEnabled: boolean;
};
export const TeamEventTypeForm = ({
isTeamAdminOrOwner,
@@ -25,7 +24,6 @@ export const TeamEventTypeForm = ({
SubmitButton,
onSuccessMutation,
onErrorMutation,
isInfiniteScrollEnabled,
}: props) => {
const { t } = useLocale();
const orgBranding = useOrgBranding();
@@ -35,11 +33,7 @@ export const TeamEventTypeForm = ({
);
const urlPrefix = orgBranding?.fullDomain ?? process.env.NEXT_PUBLIC_WEBSITE_URL;
const { form, createMutation, isManagedEventType } = useCreateEventType(
onSuccessMutation,
onErrorMutation,
isInfiniteScrollEnabled
);
const { form, createMutation, isManagedEventType } = useCreateEventType(onSuccessMutation, onErrorMutation);
const { register, setValue, formState } = form;
return (
@@ -64,7 +64,6 @@ const querySchema = z.object({
export default function CreateEventTypeDialog({
profileOptions,
isInfiniteScrollEnabled,
}: {
profileOptions: {
teamId: number | null | undefined;
@@ -72,7 +71,6 @@ export default function CreateEventTypeDialog({
image: string | undefined;
membershipRole: MembershipRole | null | undefined;
}[];
isInfiniteScrollEnabled: boolean;
}) {
const { t } = useLocale();
const router = useRouter();
@@ -115,11 +113,7 @@ export default function CreateEventTypeDialog({
);
};
const { form, createMutation, isManagedEventType } = useCreateEventType(
onSuccessMutation,
onErrorMutation,
isInfiniteScrollEnabled
);
const { form, createMutation, isManagedEventType } = useCreateEventType(onSuccessMutation, onErrorMutation);
const { register } = form;
const urlPrefix = orgBranding?.fullDomain ?? process.env.NEXT_PUBLIC_WEBSITE_URL;
@@ -149,7 +143,6 @@ export default function CreateEventTypeDialog({
SubmitButton={SubmitButton}
onSuccessMutation={onSuccessMutation}
onErrorMutation={onErrorMutation}
isInfiniteScrollEnabled={isInfiniteScrollEnabled}
/>
) : (
<Form
@@ -36,7 +36,7 @@ const querySchema = z.object({
parentId: z.coerce.number().optional().nullable(),
});
const DuplicateDialog = ({ isInfiniteScrollEnabled }: { isInfiniteScrollEnabled?: boolean }) => {
const DuplicateDialog = () => {
const utils = trpc.useUtils();
const searchParams = useCompatSearchParams();
@@ -74,15 +74,11 @@ const DuplicateDialog = ({ isInfiniteScrollEnabled }: { isInfiniteScrollEnabled?
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();
}
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
await utils.viewer.eventTypes.getEventTypesFromGroup.invalidate({
limit: 10,
group: { teamId: eventType?.teamId, parentId: eventType?.parentId },
});
showToast(
t("event_type_created_successfully", {
-4
View File
@@ -115,10 +115,6 @@ export const ORGANIZATION_SELF_SERVE_PRICE = process.env.NEXT_PUBLIC_ORGANIZATIO
export const ORGANIZATION_MIN_SEATS = 30;
export const ENABLE_INFINITE_EVENT_TYPES_FOR_ORG = JSON.parse(
`[${process.env.ENABLE_INFINITE_EVENT_TYPES_FOR_ORG || ""}]`
) as string[];
// Needed for emails in E2E
export const IS_MAILHOG_ENABLED = process.env.E2E_TEST_MAILHOG_ENABLED === "1";
export const CALCOM_VERSION = process.env.NEXT_PUBLIC_CALCOM_VERSION as string;
+5 -10
View File
@@ -13,8 +13,7 @@ import { trpc } from "@calcom/trpc/react";
export const useCreateEventType = (
onSuccessMutation: (eventType: EventType) => void,
onErrorMutation: (message: string) => void,
isInfiniteScrollEnabled: boolean
onErrorMutation: (message: string) => void
) => {
const utils = trpc.useUtils();
const { t } = useLocale();
@@ -40,14 +39,10 @@ export const useCreateEventType = (
onSuccess: async ({ eventType }) => {
onSuccessMutation(eventType);
if (isInfiniteScrollEnabled) {
await utils.viewer.eventTypes.getEventTypesFromGroup.fetchInfinite({
group: { teamId: eventType.teamId, parentId: eventType.parentId },
limit: 10,
});
} else {
await utils.viewer.eventTypes.getByViewer.invalidate();
}
await utils.viewer.eventTypes.getEventTypesFromGroup.fetchInfinite({
group: { teamId: eventType.teamId, parentId: eventType.parentId },
limit: 10,
});
form.reset();
},
-1
View File
@@ -315,7 +315,6 @@
"EMAIL_SERVER_PORT",
"EMAIL_SERVER_USER",
"EMAIL_SERVER",
"ENABLE_INFINITE_EVENT_TYPES_FOR_ORG",
"EXCHANGE_DEFAULT_EWS_URL",
"FORMBRICKS_FEEDBACK_SURVEY_ID",
"AVATARAPI_USERNAME",