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); 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 // 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({ const user = await prisma.user.findFirst({
where: { where: {
username, username,
organization: userOrgQuery(context.req.headers.host ?? "", context.params?.orgSlug), organization: userOrgQuery(context.req, context.params?.orgSlug),
}, },
select: { select: {
away: true, away: true,

View File

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

View File

@ -1,8 +1,11 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import { MembershipRole } from "@calcom/prisma/client";
import { test } from "./lib/fixtures"; import { test } from "./lib/fixtures";
import { import {
bookTimeSlot, bookTimeSlot,
doOnOrgDomain,
selectFirstAvailableTimeSlotNextMonth, selectFirstAvailableTimeSlotNextMonth,
selectSecondAvailableTimeSlotNextMonth, selectSecondAvailableTimeSlotNextMonth,
} from "./lib/testUtils"; } from "./lib/testUtils";
@ -58,3 +61,46 @@ test("dynamic booking", async ({ page, users }) => {
await expect(cancelledHeadline).toBeVisible(); 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); 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 // --- fill form
await page.fill('[name="name"]', opts?.name ?? testName); await page.fill('[name="name"]', opts?.name ?? testName);
await page.fill('[name="email"]', opts?.email ?? testEmail); await page.fill('[name="email"]', opts?.email ?? testEmail);
if (opts?.title) {
await page.fill('[name="title"]', opts.title);
}
await page.press('[name="email"]', "Enter"); await page.press('[name="email"]', "Enter");
}; };
// Provide an standalone localize utility not managed by next-i18n // Provide an standalone localize utility not managed by next-i18n
export async function localize(locale: string) { export async function localize(locale: string) {
const localeModule = `../../public/static/locales/${locale}/common.json`; const localeModule = `../../public/static/locales/${locale}/common.json`;
@ -337,6 +341,19 @@ export async function fillStripeTestCheckout(page: Page) {
await page.click(".SubmitButton--complete-Shimmer"); 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 // 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 = "This page could not be found";
export const NotFoundPageText = "ERROR 404"; export const NotFoundPageText = "ERROR 404";

View File

@ -1,4 +1,3 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
@ -9,6 +8,7 @@ import { test } from "./lib/fixtures";
import { import {
NotFoundPageText, NotFoundPageText,
bookTimeSlot, bookTimeSlot,
doOnOrgDomain,
fillStripeTestCheckout, fillStripeTestCheckout,
selectFirstAvailableTimeSlotNextMonth, selectFirstAvailableTimeSlotNextMonth,
testName, testName,
@ -459,16 +459,3 @@ test.describe("Teams - Org", () => {
await page.waitForSelector("[data-testid=day]"); 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"; : "text-bookinghighlight";
return ( return (
<div className="relative z-10 p-6"> <div className="relative z-10 p-6" data-testid="event-meta">
{isLoading && ( {isLoading && (
<m.div {...fadeInUp} initial="visible" layout> <m.div {...fadeInUp} initial="visible" layout>
<EventMetaSkeleton /> <EventMetaSkeleton />

View File

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

View File

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

View File

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