From a8975f541f176a99afbefc3bb2be5b524348454b Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 19 Dec 2023 23:12:40 +0530 Subject: [PATCH] fix: Dynamic Group Booking link for organization (#12825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Erik Co-authored-by: Omar López --- __checks__/organization.spec.ts | 22 +++++++++ apps/web/pages/[user]/[type].tsx | 2 +- .../web/pages/org/[orgSlug]/[user]/[type].tsx | 10 ++-- .../playwright/dynamic-booking-pages.e2e.ts | 46 +++++++++++++++++++ apps/web/playwright/lib/testUtils.ts | 19 +++++++- apps/web/playwright/teams.e2e.ts | 15 +----- .../bookings/Booker/components/EventMeta.tsx | 2 +- .../bookings/components/event-meta/Title.tsx | 6 ++- .../features/bookings/lib/handleNewBooking.ts | 12 ++--- .../ee/organizations/lib/orgDomains.ts | 4 +- packages/ui/components/avatar/AvatarGroup.tsx | 1 + 11 files changed, 106 insertions(+), 33 deletions(-) diff --git a/__checks__/organization.spec.ts b/__checks__/organization.spec.ts index 685f97e281..4c6a047341 100644 --- a/__checks__/organization.spec.ts +++ b/__checks__/organization.spec.ts @@ -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 diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index 96b2482bf8..b328800c45 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -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, diff --git a/apps/web/pages/org/[orgSlug]/[user]/[type].tsx b/apps/web/pages/org/[orgSlug]/[user]/[type].tsx index c95ec03fb8..abf833ec14 100644 --- a/apps/web/pages/org/[orgSlug]/[user]/[type].tsx +++ b/apps/web/pages/org/[orgSlug]/[user]/[type].tsx @@ -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: { diff --git a/apps/web/playwright/dynamic-booking-pages.e2e.ts b/apps/web/playwright/dynamic-booking-pages.e2e.ts index f41fe4c91b..bf30c2bd01 100644 --- a/apps/web/playwright/dynamic-booking-pages.e2e.ts +++ b/apps/web/playwright/dynamic-booking-pages.e2e.ts @@ -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(); + } + ); + }); +}); diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 25c6d1a3fa..8764ed96a5 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -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 +) { + 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"; diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts index a7504c1433..0491241e0b 100644 --- a/apps/web/playwright/teams.e2e.ts +++ b/apps/web/playwright/teams.e2e.ts @@ -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 -) { - if (!orgSlug) { - throw new Error("orgSlug is not available"); - } - page.setExtraHTTPHeaders({ - "x-cal-force-slug": orgSlug, - }); - await callback({ page }); -} diff --git a/packages/features/bookings/Booker/components/EventMeta.tsx b/packages/features/bookings/Booker/components/EventMeta.tsx index f282e48d5e..623704490e 100644 --- a/packages/features/bookings/Booker/components/EventMeta.tsx +++ b/packages/features/bookings/Booker/components/EventMeta.tsx @@ -57,7 +57,7 @@ export const EventMeta = () => { : "text-bookinghighlight"; return ( -
+
{isLoading && ( diff --git a/packages/features/bookings/components/event-meta/Title.tsx b/packages/features/bookings/components/event-meta/Title.tsx index a147b8cfc2..7046a0185a 100644 --- a/packages/features/bookings/components/event-meta/Title.tsx +++ b/packages/features/bookings/components/event-meta/Title.tsx @@ -11,5 +11,9 @@ interface EventTitleProps { export const EventTitle = ({ children, as, className }: EventTitleProps) => { const El = as || "h1"; - return {children}; + return ( + + {children} + + ); }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 9d6249474d..5c878434f5 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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>[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) { diff --git a/packages/features/ee/organizations/lib/orgDomains.ts b/packages/features/ee/organizations/lib/orgDomains.ts index 6cf795b55b..878455235c 100644 --- a/packages/features/ee/organizations/lib/orgDomains.ts +++ b/packages/features/ee/organizations/lib/orgDomains.ts @@ -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; } diff --git a/packages/ui/components/avatar/AvatarGroup.tsx b/packages/ui/components/avatar/AvatarGroup.tsx index ca8cb95606..113886e67b 100644 --- a/packages/ui/components/avatar/AvatarGroup.tsx +++ b/packages/ui/components/avatar/AvatarGroup.tsx @@ -31,6 +31,7 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) { {displayedAvatars.map((item, idx) => (