feat: dynamic group links + flexible schedule (#9931)

* redirect user to /[user]/[type] for dynamic booking in /[user]

* feat: dynamic group links

* fix: type errors

* fix: more type errors

* Booker components cannot rely on useRouter

* Rename 15min/30/60 to dynamic

* Fixed a blocking test, unclear what it was waiting for

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
mohammed hussam
2023-07-25 22:48:09 +01:00
committed by GitHub
co-authored by Alex van Andel
parent 829a103f6b
commit 94b4507901
4 changed files with 52 additions and 153 deletions
+32 -116
View File
@@ -13,12 +13,7 @@ import {
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
import defaultEvents, {
getDynamicEventDescription,
getGroupName,
getUsernameList,
getUsernameSlugLink,
} from "@calcom/lib/defaultEvents";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
@@ -26,7 +21,7 @@ import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { Avatar, AvatarGroup, HeadSeo } from "@calcom/ui";
import { Avatar, HeadSeo } from "@calcom/ui";
import { Verified, ArrowRight } from "@calcom/ui/components/icon";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -38,16 +33,7 @@ import { ssrInit } from "@server/lib/ssr";
export type UserPageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export function UserPage(props: UserPageProps) {
const {
users,
profile,
eventTypes,
isDynamicGroup,
dynamicNames,
dynamicUsernames,
isSingleUser,
markdownStrippedBio,
} = props;
const { users, profile, eventTypes, isSingleUser, markdownStrippedBio } = props;
const [user] = users; //To be used when we only have a single user, not dynamic group
useTheme(user.theme);
const { t } = useLocale();
@@ -55,47 +41,6 @@ export function UserPage(props: UserPageProps) {
const isBioEmpty = !user.bio || !user.bio.replace("<p><br></p>", "").length;
const groupEventTypes = props.users.some((user) => !user.allowDynamicBooking) ? (
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border ">
<div className="text-muted p-8 text-center">
<h2 className="font-cal text-default mb-2 text-3xl">{" " + t("unavailable")}</h2>
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled") as string}</p>
</div>
</div>
</div>
) : (
<ul>
{eventTypes.map((type, index) => (
<li
key={index}
className=" border-subtle bg-default dark:bg-muted dark:hover:bg-emphasis hover:bg-muted group relative border-b first:rounded-t-md last:rounded-b-md last:border-b-0">
<ArrowRight className="text-emphasis absolute right-3 top-3 h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100" />
<Link
href={getUsernameSlugLink({ users: props.users, slug: type.slug })}
className="flex justify-between px-6 py-4"
data-testid="event-type-link">
<div className="flex-shrink">
<p className=" text-emphasis text-sm font-semibold">{type.title}</p>
<EventTypeDescription className="text-sm" eventType={type} />
</div>
<div className="mt-1 self-center">
<AvatarGroup
truncateAfter={4}
className="flex flex-shrink-0"
size="sm"
items={props.users.map((user) => ({
alt: user.name || "",
image: user.avatar,
}))}
/>
</div>
</Link>
</li>
))}
</ul>
);
const isEmbed = useIsEmbed(props.isEmbed);
const eventTypeListItemEmbedStyles = useEmbedStyles("eventTypeListItem");
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
@@ -117,14 +62,12 @@ export function UserPage(props: UserPageProps) {
return (
<>
<HeadSeo
title={isDynamicGroup ? dynamicNames.join(", ") : nameOrUsername}
description={isDynamicGroup ? `Book events with ${dynamicUsernames.join(", ")}` : markdownStrippedBio}
title={nameOrUsername}
description={markdownStrippedBio}
meeting={{
title: isDynamicGroup ? "" : markdownStrippedBio,
title: markdownStrippedBio,
profile: { name: `${profile.name}`, image: null },
users: isDynamicGroup
? dynamicUsernames.map((username, index) => ({ username, name: dynamicNames[index] }))
: [{ username: `${user.username}`, name: `${user.name}` }],
users: [{ username: `${user.username}`, name: `${user.name}` }],
}}
/>
@@ -165,8 +108,6 @@ export function UserPage(props: UserPageProps) {
<p className="mx-auto max-w-md">{t("user_away_description") as string}</p>
</div>
</div>
) : isDynamicGroup ? ( //When we deal with dynamic group (users > 1)
groupEventTypes
) : (
eventTypes.map((type) => (
<div
@@ -288,6 +229,21 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
});
const isDynamicGroup = usersWithoutAvatar.length > 1;
if (isDynamicGroup) {
return {
redirect: {
permanent: false,
destination: `/${usernameList.join("+")}/dynamic`,
},
} as {
redirect: {
permanent: false;
destination: string;
};
};
}
const users = usersWithoutAvatar.map((user) => ({
...user,
avatar: `/${user.username}/avatar.png`,
@@ -300,45 +256,18 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
notFound: true;
};
}
const isDynamicGroup = users.length > 1;
if (isDynamicGroup) {
// sort and be in the same order as usernameList so first user is the first user in the list
users.sort((a, b) => {
const aIndex = (a.username && usernameList.indexOf(a.username)) || 0;
const bIndex = (b.username && usernameList.indexOf(b.username)) || 0;
return aIndex - bIndex;
});
}
const dynamicNames = isDynamicGroup
? users.map((user) => {
return user.name || "";
})
: [];
const [user] = users; //to be used when dealing with single user, not dynamic group
const profile = isDynamicGroup
? {
name: getGroupName(dynamicNames),
image: null,
theme: null,
weekStart: "Sunday",
brandColor: "",
darkBrandColor: "",
allowDynamicBooking: !users.some((user) => {
return !user.allowDynamicBooking;
}),
}
: {
name: user.name || user.username,
image: user.avatar,
theme: user.theme,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
};
const profile = {
name: user.name || user.username,
image: user.avatar,
theme: user.theme,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
};
const eventTypesWithHidden = isDynamicGroup ? [] : await getEventTypesWithHiddenFromDB(user.id);
const eventTypesWithHidden = await getEventTypesWithHiddenFromDB(user.id);
const dataFetchEnd = Date.now();
if (context.query.log === "1") {
context.res.setHeader("X-Data-Fetch-Time", `${dataFetchEnd - dataFetchStart}ms`);
@@ -352,11 +281,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
}));
const isSingleUser = users.length === 1;
const dynamicUsernames = isDynamicGroup
? users.map((user) => {
return user.username || "";
})
: [];
const safeBio = markdownToSafeHTML(user.bio) || "";
@@ -368,20 +292,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
safeBio,
profile,
// Dynamic group has no theme preference right now. It uses system theme.
themeBasis: isDynamicGroup ? null : user.username,
themeBasis: user.username,
user: {
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
},
eventTypes: isDynamicGroup
? defaultEvents.map((event) => {
event.description = getDynamicEventDescription(dynamicUsernames, event.slug);
return event;
})
: eventTypes,
eventTypes,
trpcState: ssr.dehydrate(),
isDynamicGroup,
dynamicNames,
dynamicUsernames,
isSingleUser,
markdownStrippedBio,
},
@@ -236,10 +236,7 @@ test.describe("Routing Forms", () => {
await user.apiLogin();
await page.goto(`/apps/routing-forms/reporting/${routingForm.id}`);
// Can't keep waiting forever. So, added a timeout of 5000ms
await page.waitForResponse((response) => response.url().includes("appRoutingForms/report"), {
timeout: 5000,
});
const headerEls = page.locator("[data-testid='reporting-header'] th");
// Once the response is there, React would soon render it, so 500ms is enough
// FIXME: Sometimes it takes more than 500ms, so added a timeout of 1000ms for now. There might be something wrong with rendering.
@@ -14,17 +14,23 @@ export const EventDuration = ({ event }: { event: PublicEvent }) => {
state.setSelectedDuration,
]);
const isDynamicEvent = "isDynamic" in event && event.isDynamic;
// Sets initial value of selected duration to the default duration.
useEffect(() => {
// Only store event duration in url if event has multiple durations.
if (!selectedDuration && event.metadata?.multipleDuration) setSelectedDuration(event.length);
}, [selectedDuration, setSelectedDuration, event.length, event.metadata?.multipleDuration]);
if (!selectedDuration && (event.metadata?.multipleDuration || isDynamicEvent))
setSelectedDuration(event.length);
}, [selectedDuration, setSelectedDuration, event.metadata?.multipleDuration, event.length, isDynamicEvent]);
if (!event?.metadata?.multipleDuration) return <>{t("multiple_duration_mins", { count: event.length })}</>;
if (!event?.metadata?.multipleDuration && !isDynamicEvent)
return <>{t("multiple_duration_mins", { count: event.length })}</>;
const durations = event?.metadata?.multipleDuration || [15, 30, 60];
return (
<div className="flex flex-wrap gap-2">
{event.metadata.multipleDuration.map((duration) => (
{durations.map((duration) => (
<Badge
variant="gray"
className={classNames(selectedDuration === duration && "bg-brand-default text-brand")}
+9 -29
View File
@@ -106,38 +106,18 @@ const commons = {
}),
};
const min15Event = {
length: 15,
slug: "15",
title: "15min",
eventName: "Dynamic Collective 15min Event",
description: "Dynamic Collective 15min Event",
descriptionAsSafeHTML: "Dynamic Collective 15min Event",
const dynamicEvent = {
length: 30,
slug: "dynamic",
title: "Dynamic",
eventName: "Dynamic Event",
description: "",
descriptionAsSafeHTML: "",
position: 0,
...commons,
};
const min30Event = {
length: 30,
slug: "30",
title: "30min",
eventName: "Dynamic Collective 30min Event",
description: "Dynamic Collective 30min Event",
descriptionAsSafeHTML: "Dynamic Collective 30min Event",
position: 1,
...commons,
};
const min60Event = {
length: 60,
slug: "60",
title: "60min",
eventName: "Dynamic Collective 60min Event",
description: "Dynamic Collective 60min Event",
descriptionAsSafeHTML: "Dynamic Collective 60min Event",
position: 2,
...commons,
};
const defaultEvents = [min15Event, min30Event, min60Event];
const defaultEvents = [dynamicEvent];
export const getDynamicEventDescription = (dynamicUsernames: string[], slug: string): string => {
return `Book a ${slug} min event with ${dynamicUsernames.join(", ")}`;
@@ -152,7 +132,7 @@ export const getDefaultEvent = (slug: string) => {
const event = defaultEvents.find((obj) => {
return obj.slug === slug;
});
return event || min15Event;
return event || dynamicEvent;
};
export const getGroupName = (usernameList: string[]): string => {