fix: Dynamic Group Booking link for organization (#12825)

Co-authored-by: Erik <erik@erosemberg.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Hariom Balhara 2023-12-19 23:12:40 +05:30 committed by GitHub
parent 31b88c5537
commit a8975f541f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 106 additions and 33 deletions

View File

@ -41,6 +41,28 @@ test.describe("Org", () => {
await expectPageToBeServerSideRendered(page);
});
});
test.describe("Dynamic Group Booking", () => {
test("Dynamic Group booking link should load", async ({ page }) => {
const users = [
{
username: "peer",
name: "Peer Richelsen",
},
{
username: "bailey",
name: "Bailey Pumfleet",
},
];
const response = await page.goto(`http://i.cal.com/${users[0].username}+${users[1].username}`);
expect(response?.status()).toBe(200);
expect(await page.locator('[data-testid="event-title"]').textContent()).toBe("Dynamic");
expect(await page.locator('[data-testid="event-meta"]').textContent()).toContain(users[0].name);
expect(await page.locator('[data-testid="event-meta"]').textContent()).toContain(users[1].name);
// 2 users and 1 for the organization(2+1)
expect((await page.locator('[data-testid="event-meta"] [data-testid="avatar"]').all()).length).toBe(3);
});
});
});
// This ensures that the route is actually mapped to a page that is using withEmbedSsr

View File

@ -185,7 +185,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const user = await prisma.user.findFirst({
where: {
username,
organization: userOrgQuery(context.req.headers.host ?? "", context.params?.orgSlug),
organization: userOrgQuery(context.req, context.params?.orgSlug),
},
select: {
away: true,

View File

@ -14,15 +14,15 @@ import TeamTypePage, { getServerSideProps as GSSTeamTypePage } from "../../../te
const paramsSchema = z.object({
orgSlug: z.string().transform((s) => slugify(s)),
user: z.string().transform((s) => slugify(s)),
user: z.string(),
type: z.string().transform((s) => slugify(s)),
});
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const { user: teamOrUserSlug, orgSlug, type } = paramsSchema.parse(ctx.params);
const { user: teamOrUserSlugOrDynamicGroup, orgSlug, type } = paramsSchema.parse(ctx.params);
const team = await prisma.team.findFirst({
where: {
slug: teamOrUserSlug,
slug: slugify(teamOrUserSlugOrDynamicGroup),
parentId: {
not: null,
},
@ -34,7 +34,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
});
if (team) {
const params = { slug: teamOrUserSlug, type };
const params = { slug: teamOrUserSlugOrDynamicGroup, type };
return GSSTeamTypePage({
...ctx,
params: {
@ -47,7 +47,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
},
});
}
const params = { user: teamOrUserSlug, type };
const params = { user: teamOrUserSlugOrDynamicGroup, type };
return GSSUserTypePage({
...ctx,
params: {

View File

@ -1,8 +1,11 @@
import { expect } from "@playwright/test";
import { MembershipRole } from "@calcom/prisma/client";
import { test } from "./lib/fixtures";
import {
bookTimeSlot,
doOnOrgDomain,
selectFirstAvailableTimeSlotNextMonth,
selectSecondAvailableTimeSlotNextMonth,
} from "./lib/testUtils";
@ -58,3 +61,46 @@ test("dynamic booking", async ({ page, users }) => {
await expect(cancelledHeadline).toBeVisible();
});
});
test.describe("Organization:", () => {
test.afterEach(({ orgs, users }) => {
orgs.deleteAll();
users.deleteAll();
});
test("Can book a time slot for an organization", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const user1 = await users.create({
organizationId: org.id,
name: "User 1",
roleInOrganization: MembershipRole.ADMIN,
});
const user2 = await users.create({
organizationId: org.id,
name: "User 2",
roleInOrganization: MembershipRole.ADMIN,
});
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await page.goto(`/${user1.username}+${user2.username}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page, {
title: "Test meeting",
});
await expect(page.getByTestId("success-page")).toBeVisible();
// All the teammates should be in the booking
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expect(page.getByText(user1.name!, { exact: true })).toBeVisible();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expect(page.getByText(user2.name!, { exact: true })).toBeVisible();
}
);
});
});

View File

@ -134,12 +134,16 @@ export async function bookFirstEvent(page: Page) {
await bookEventOnThisPage(page);
}
export const bookTimeSlot = async (page: Page, opts?: { name?: string; email?: string }) => {
export const bookTimeSlot = async (page: Page, opts?: { name?: string; email?: string; title?: string }) => {
// --- fill form
await page.fill('[name="name"]', opts?.name ?? testName);
await page.fill('[name="email"]', opts?.email ?? testEmail);
if (opts?.title) {
await page.fill('[name="title"]', opts.title);
}
await page.press('[name="email"]', "Enter");
};
// Provide an standalone localize utility not managed by next-i18n
export async function localize(locale: string) {
const localeModule = `../../public/static/locales/${locale}/common.json`;
@ -337,6 +341,19 @@ export async function fillStripeTestCheckout(page: Page) {
await page.click(".SubmitButton--complete-Shimmer");
}
export async function doOnOrgDomain(
{ orgSlug, page }: { orgSlug: string | null; page: Page },
callback: ({ page }: { page: Page }) => Promise<void>
) {
if (!orgSlug) {
throw new Error("orgSlug is not available");
}
page.setExtraHTTPHeaders({
"x-cal-force-slug": orgSlug,
});
await callback({ page });
}
// When App directory is there, this is the 404 page text. It is commented till it's disabled
// export const NotFoundPageText = "This page could not be found";
export const NotFoundPageText = "ERROR 404";

View File

@ -1,4 +1,3 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
@ -9,6 +8,7 @@ import { test } from "./lib/fixtures";
import {
NotFoundPageText,
bookTimeSlot,
doOnOrgDomain,
fillStripeTestCheckout,
selectFirstAvailableTimeSlotNextMonth,
testName,
@ -459,16 +459,3 @@ test.describe("Teams - Org", () => {
await page.waitForSelector("[data-testid=day]");
});
});
async function doOnOrgDomain(
{ orgSlug, page }: { orgSlug: string | null; page: Page },
callback: ({ page }: { page: Page }) => Promise<void>
) {
if (!orgSlug) {
throw new Error("orgSlug is not available");
}
page.setExtraHTTPHeaders({
"x-cal-force-slug": orgSlug,
});
await callback({ page });
}

View File

@ -57,7 +57,7 @@ export const EventMeta = () => {
: "text-bookinghighlight";
return (
<div className="relative z-10 p-6">
<div className="relative z-10 p-6" data-testid="event-meta">
{isLoading && (
<m.div {...fadeInUp} initial="visible" layout>
<EventMetaSkeleton />

View File

@ -11,5 +11,9 @@ interface EventTitleProps {
export const EventTitle = ({ children, as, className }: EventTitleProps) => {
const El = as || "h1";
return <El className={classNames("text-text text-xl font-semibold", className)}>{children}</El>;
return (
<El data-testid="event-title" className={classNames("text-text text-xl font-semibold", className)}>
{children}
</El>
);
};

View File

@ -1,6 +1,7 @@
import type { App, Attendee, DestinationCalendar, EventTypeCustomInput } from "@prisma/client";
import { Prisma } from "@prisma/client";
import async from "async";
import type { IncomingMessage } from "http";
import { isValidPhoneNumber } from "libphonenumber-js";
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
@ -372,21 +373,16 @@ type IsFixedAwareUser = User & {
organization: { slug: string };
};
const loadUsers = async (
eventType: NewBookingEventType,
dynamicUserList: string[],
reqHeadersHost: string | undefined
) => {
const loadUsers = async (eventType: NewBookingEventType, dynamicUserList: string[], req: IncomingMessage) => {
try {
if (!eventType.id) {
if (!Array.isArray(dynamicUserList) || dynamicUserList.length === 0) {
throw new Error("dynamicUserList is not properly defined or empty.");
}
const users = await prisma.user.findMany({
where: {
username: { in: dynamicUserList },
organization: userOrgQuery(reqHeadersHost ? reqHeadersHost.replace(/^https?:\/\//, "") : ""),
organization: userOrgQuery(req),
},
select: {
...userSelect.select,
@ -969,7 +965,7 @@ async function handler(
let users: (Awaited<ReturnType<typeof loadUsers>>[number] & {
isFixed?: boolean;
metadata?: Prisma.JsonValue;
})[] = await loadUsers(eventType, dynamicUserList, req.headers.host);
})[] = await loadUsers(eventType, dynamicUserList, req);
const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking);
if (!isDynamicAllowed && !eventTypeId) {

View File

@ -145,7 +145,7 @@ export function whereClauseForOrgWithSlugOrRequestedSlug(slug: string) {
} satisfies Prisma.TeamWhereInput;
}
export function userOrgQuery(hostname: string, fallback?: string | string[]) {
const { currentOrgDomain, isValidOrgDomain } = getOrgDomainConfigFromHostname({ hostname, fallback });
export function userOrgQuery(req: IncomingMessage | undefined, fallback?: string | string[]) {
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, fallback);
return isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null;
}

View File

@ -31,6 +31,7 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
{displayedAvatars.map((item, idx) => (
<li key={idx} className="-mr-[4px] inline-block">
<Avatar
data-testid="avatar"
className="border-subtle"
imageSrc={item.image}
title={item.title}