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:
parent
31b88c5537
commit
a8975f541f
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user