Merge branch 'feat/organizations' into feat/organizations-banner
This commit is contained in:
commit
5ecb4ba26c
16
.env.example
16
.env.example
|
@ -5,6 +5,7 @@
|
|||
# - SHARED
|
||||
# - NEXTAUTH
|
||||
# - E-MAIL SETTINGS
|
||||
# - ORGANIZATIONS
|
||||
|
||||
# - LICENSE (DEPRECATED) ************************************************************************************
|
||||
# https://github.com/calcom/cal.com/blob/main/LICENSE
|
||||
|
@ -32,7 +33,9 @@ PRISMA_GENERATE_DATAPROXY=
|
|||
# ***********************************************************************************************************
|
||||
|
||||
# - SHARED **************************************************************************************************
|
||||
NEXT_PUBLIC_WEBAPP_URL='http://app.cal.local:3000'
|
||||
# Set this to http://app.cal.local:3000 if you want to enable organizations, and
|
||||
# check variable ORGANIZATIONS_ENABLED at the bottom of this file
|
||||
NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000'
|
||||
# Change to 'http://localhost:3001' if running the website simultaneously
|
||||
NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000'
|
||||
NEXT_PUBLIC_CONSOLE_URL='http://localhost:3004'
|
||||
|
@ -184,7 +187,16 @@ EDGE_CONFIG=
|
|||
|
||||
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
||||
|
||||
# Vercel
|
||||
# - ORGANIZATIONS *******************************************************************************************
|
||||
# Enable Organizations non-prod domain setup, works in combination with organizations feature flag
|
||||
# This is mainly needed locally, because for orgs to work a full domain name needs to point
|
||||
# to the app, i.e. app.cal.local instead of using localhost, which is very disruptive
|
||||
#
|
||||
# This variable should only be set to 1 or true if you are in a non-prod environment and you want to
|
||||
# use organizations
|
||||
ORGANIZATIONS_ENABLED=
|
||||
|
||||
# Vercel Config to create subdomains for organizations
|
||||
PROJECT_ID_VERCEL=
|
||||
TEAM_ID_VERCEL=
|
||||
AUTH_BEARER_TOKEN_VERCEL=
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { DefaultSeo } from "next-seo";
|
||||
import { Inter } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import Head from "next/head";
|
||||
import Script from "next/script";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
|
@ -60,6 +61,12 @@ function PageWrapper(props: AppProps) {
|
|||
|
||||
return (
|
||||
<AppProviders {...providerProps}>
|
||||
<Head>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
||||
/>
|
||||
</Head>
|
||||
<DefaultSeo
|
||||
// Set canonical to https://cal.com or self-hosted URL
|
||||
canonical={
|
||||
|
|
|
@ -161,7 +161,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||
brand === "#fff" || brand === "#ffffff" ? "" : ""
|
||||
)}
|
||||
onClick={() => reserveSlot(slot)}
|
||||
data-testid="time">
|
||||
data-testid="time"
|
||||
data-disabled="false">
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{!!seatsPerTimeSlot && (
|
||||
<p
|
||||
|
|
|
@ -59,6 +59,7 @@ export default function CancelBooking(props: Props) {
|
|||
<div className="mt-5 sm:mt-6">
|
||||
<label className="text-default font-medium">{t("cancellation_reason")}</label>
|
||||
<TextArea
|
||||
data-testid="cancel_reason"
|
||||
ref={cancelBookingRef}
|
||||
placeholder={t("cancellation_reason_placeholder")}
|
||||
value={cancellationReason}
|
||||
|
@ -75,7 +76,7 @@ export default function CancelBooking(props: Props) {
|
|||
{t("nevermind")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="cancel"
|
||||
data-testid="confirm_cancel"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "2.9.7",
|
||||
"version": "2.9.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
|
|
@ -48,10 +48,6 @@ class MyDocument extends Document<Props> {
|
|||
<meta name="msapplication-TileColor" content="#ff0000" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f9fafb" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1C1C" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<body
|
||||
|
|
|
@ -4,6 +4,7 @@ import { z } from "zod";
|
|||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { viewerRouter } from "@calcom/trpc/server/routers/viewer/_router";
|
||||
|
@ -38,9 +39,22 @@ async function handler(req: NextApiRequest, res: NextApiResponse<Response>) {
|
|||
where: { id: userId },
|
||||
});
|
||||
|
||||
/** We shape the session as required by tRPC router */
|
||||
async function sessionGetter() {
|
||||
return {
|
||||
user: {
|
||||
id: userId,
|
||||
username: "" /* Not used in this context */,
|
||||
role: UserPermissionRole.USER,
|
||||
},
|
||||
hasValidLicense: true,
|
||||
expires: "" /* Not used in this context */,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res });
|
||||
const ctx = await createContext({ req, res }, sessionGetter);
|
||||
const caller = viewerRouter.createCaller({
|
||||
...ctx,
|
||||
req,
|
||||
|
@ -55,7 +69,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse<Response>) {
|
|||
});
|
||||
} catch (e) {
|
||||
let message = "Error confirming booking";
|
||||
if (e instanceof TRPCError) message = e.message;
|
||||
if (e instanceof TRPCError) message = (e as TRPCError).message;
|
||||
res.redirect(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`);
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -109,15 +109,14 @@ testBothBookers.describe("pro user", () => {
|
|||
await pro.apiLogin();
|
||||
|
||||
await page.goto("/bookings/upcoming");
|
||||
await page.locator('[data-testid="cancel"]').first().click();
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking/");
|
||||
});
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
const cancelledHeadline = await page.locator('[data-testid="cancelled-headline"]').innerText();
|
||||
|
||||
expect(cancelledHeadline).toBe("This event is cancelled");
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
|
||||
await expect(page.locator(`[data-testid="attendee-email-${testEmail}"]`)).toHaveText(testEmail);
|
||||
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { expect } from "@playwright/test";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { uuid } from "short-uuid";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
@ -21,7 +22,14 @@ async function createUserWithSeatedEvent(users: Fixtures["users"]) {
|
|||
const slug = "seats";
|
||||
const user = await users.create({
|
||||
eventTypes: [
|
||||
{ title: "Seated event", slug, seatsPerTimeSlot: 10, requiresConfirmation: true, length: 30 },
|
||||
{
|
||||
title: "Seated event",
|
||||
slug,
|
||||
seatsPerTimeSlot: 10,
|
||||
requiresConfirmation: true,
|
||||
length: 30,
|
||||
disableGuests: true, // should always be true for seated events
|
||||
},
|
||||
],
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
@ -105,21 +113,13 @@ testBothBookers.describe("Booking with Seats", (bookerVariant) => {
|
|||
});
|
||||
});
|
||||
|
||||
// TODO: Make E2E test: Attendee #1 should be able to cancel his booking
|
||||
// todo("Attendee #1 should be able to cancel his booking");
|
||||
// TODO: Make E2E test: Attendee #1 should be able to reschedule his booking
|
||||
// todo("Attendee #1 should be able to reschedule his booking");
|
||||
// TODO: Make E2E test: All attendees canceling should delete the booking for the User
|
||||
// todo("All attendees canceling should delete the booking for the User");
|
||||
});
|
||||
|
||||
testBothBookers.describe("Reschedule for booking with seats", () => {
|
||||
test("Should reschedule booking with seats", async ({ page, users, bookings }) => {
|
||||
test(`Attendees can cancel a seated event time slot`, async ({ page, users, bookings }) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
|
@ -137,6 +137,81 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
|
|||
data: bookingSeats,
|
||||
});
|
||||
|
||||
await test.step("Attendee #1 should be able to cancel their booking", async () => {
|
||||
await page.goto(`/booking/${booking.uid}?seatReferenceUid=${bookingSeats[0].referenceUid}`);
|
||||
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.fill('[data-testid="cancel_reason"]', "Double booked!");
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
|
||||
// Old booking should still exist, with one less attendee
|
||||
const updatedBooking = await prisma.booking.findFirst({
|
||||
where: { id: bookingSeats[0].bookingId },
|
||||
include: { attendees: true },
|
||||
});
|
||||
|
||||
const attendeeIds = updatedBooking?.attendees.map(({ id }) => id);
|
||||
expect(attendeeIds).toHaveLength(2);
|
||||
expect(attendeeIds).not.toContain(bookingAttendees[0].id);
|
||||
});
|
||||
|
||||
await test.step("All attendees cancelling should delete the booking for the user", async () => {
|
||||
// The remaining 2 attendees cancel
|
||||
for (let i = 1; i < bookingSeats.length; i++) {
|
||||
await page.goto(`/booking/${booking.uid}?seatReferenceUid=${bookingSeats[i].referenceUid}`);
|
||||
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.fill('[data-testid="cancel_reason"]', "Double booked!");
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
}
|
||||
|
||||
// Should expect old booking to be cancelled
|
||||
const updatedBooking = await prisma.booking.findFirst({
|
||||
where: { id: bookingSeats[0].bookingId },
|
||||
});
|
||||
expect(updatedBooking).not.toBeNull();
|
||||
expect(updatedBooking?.status).toBe(BookingStatus.CANCELLED);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
testBothBookers.describe("Reschedule for booking with seats", () => {
|
||||
test("Should reschedule booking with seats", async ({ page, users, bookings }) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: `first+seats-${uuid()}@cal.com`, timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: `second+seats-${uuid()}@cal.com`, timeZone: "Europe/Berlin" },
|
||||
{ name: "John Third", email: `third+seats-${uuid()}@cal.com`, timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[2].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
const references = await prisma.bookingSeat.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
|
@ -156,6 +231,20 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
|
|||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
|
||||
// Should expect new booking to be created for John Third
|
||||
const newBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
attendees: {
|
||||
some: { email: bookingAttendees[2].email },
|
||||
},
|
||||
},
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
expect(newBooking?.status).toBe(BookingStatus.PENDING);
|
||||
expect(newBooking?.attendees.length).toBe(1);
|
||||
expect(newBooking?.attendees[0].name).toBe("John Third");
|
||||
expect(newBooking?.seatsReferences.length).toBe(1);
|
||||
|
||||
// Should expect old booking to be accepted with two attendees
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
|
@ -260,7 +349,7 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
|
|||
// Now we cancel the booking as the organizer
|
||||
await page.goto(`/booking/${booking.uid}?cancel=true`);
|
||||
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
|
@ -309,7 +398,7 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
|
|||
`/booking/${references[0].referenceUid}?cancel=true&seatReferenceUid=${references[0].referenceUid}`
|
||||
);
|
||||
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
|
@ -364,7 +453,7 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
|
|||
`/booking/${booking.uid}?cancel=true&allRemainingBookings=false&seatReferenceUid=${bookingSeats[0].referenceUid}`
|
||||
);
|
||||
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
|
@ -375,7 +464,7 @@ testBothBookers.describe("Reschedule for booking with seats", () => {
|
|||
);
|
||||
|
||||
// Page should not be 404
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
|
|
|
@ -47,14 +47,13 @@ test.skip("dynamic booking", async ({ page, users }) => {
|
|||
|
||||
await test.step("Can cancel the recently created booking", async () => {
|
||||
await page.goto("/bookings/upcoming");
|
||||
await page.locator('[data-testid="cancel"]').first().click();
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
});
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
const cancelledHeadline = await page.locator('[data-testid="cancelled-headline"]').innerText();
|
||||
|
||||
expect(cancelledHeadline).toBe("This event is cancelled");
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -62,6 +62,7 @@ export const createBookingsFixture = (page: Page) => {
|
|||
store.bookings.push(bookingFixture);
|
||||
return bookingFixture;
|
||||
},
|
||||
update: async (args: Prisma.BookingUpdateArgs) => await prisma.booking.update(args),
|
||||
get: () => store.bookings,
|
||||
delete: async (id: number) => {
|
||||
await prisma.booking.delete({
|
||||
|
@ -80,7 +81,11 @@ const createBookingFixture = (booking: Booking, page: Page) => {
|
|||
return {
|
||||
id: store.booking.id,
|
||||
uid: store.booking.uid,
|
||||
self: async () => await prisma.booking.findUnique({ where: { id: store.booking.id } }),
|
||||
self: async () =>
|
||||
await prisma.booking.findUnique({
|
||||
where: { id: store.booking.id },
|
||||
include: { attendees: true, seatsReferences: true },
|
||||
}),
|
||||
delete: async () => await prisma.booking.delete({ where: { id: store.booking.id } }),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -77,6 +77,7 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
|
|||
{ title: "30 min", slug: "30-min", length: 30 },
|
||||
{ title: "Paid", slug: "paid", length: 30, price: 1000 },
|
||||
{ title: "Opt in", slug: "opt-in", requiresConfirmation: true, length: 30 },
|
||||
{ title: "Seated", slug: "seated", seatsPerTimeSlot: 2, length: 30 },
|
||||
];
|
||||
|
||||
if (opts?.eventTypes) defaultEventTypes = defaultEventTypes.concat(opts.eventTypes);
|
||||
|
|
|
@ -92,7 +92,7 @@ export async function selectFirstAvailableTimeSlotNextMonth(page: Page) {
|
|||
await page.waitForTimeout(1000);
|
||||
// TODO: Find out why the first day is always booked on tests
|
||||
await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
|
||||
await page.locator('[data-testid="time"]').nth(0).click();
|
||||
await page.locator('[data-testid="time"][data-disabled="false"]').nth(0).click();
|
||||
}
|
||||
|
||||
export async function selectSecondAvailableTimeSlotNextMonth(page: Page) {
|
||||
|
@ -110,7 +110,7 @@ export async function selectSecondAvailableTimeSlotNextMonth(page: Page) {
|
|||
await page.waitForTimeout(1000);
|
||||
// TODO: Find out why the first day is always booked on tests
|
||||
await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
|
||||
await page.locator('[data-testid="time"]').nth(1).click();
|
||||
await page.locator('[data-testid="time"][data-disabled="false"]').nth(1).click();
|
||||
}
|
||||
|
||||
async function bookEventOnThisPage(page: Page) {
|
||||
|
|
|
@ -12,33 +12,28 @@ test.describe("Managed Event Types tests", () => {
|
|||
const memberUser = await users.create();
|
||||
// First we work with owner user, logging in
|
||||
await adminUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
// Making sure page loads completely
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Let's create a team
|
||||
await page.goto("/teams");
|
||||
await page.goto("/settings/teams/new");
|
||||
|
||||
await test.step("Managed event option exists for team admin", async () => {
|
||||
// Proceed to create a team
|
||||
await page.locator("text=Create Team").click();
|
||||
await page.waitForURL("/settings/teams/new");
|
||||
|
||||
// Filling team creation form wizard
|
||||
await page.locator('input[name="name"]').waitFor();
|
||||
await page.locator('input[name="name"]').fill(`${adminUser.username}'s Team`);
|
||||
await page.locator("text=Continue").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
|
||||
await page.locator('[data-testid="new-member-button"]').click();
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(`${memberUser.username}@example.com`);
|
||||
await page.locator('[data-testid="invite-new-member-button"]').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
// wait for the second member to be added to the pending-member-list.
|
||||
await page.getByTestId("pending-member-list").locator("li:nth-child(2)").waitFor();
|
||||
// and publish
|
||||
await page.locator("text=Publish team").click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.waitForURL("/settings/teams/**");
|
||||
// Going to create an event type
|
||||
await page.goto("/event-types");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.click("[data-testid=new-event-type-dropdown]");
|
||||
await page.click("[data-testid=option-team-1]");
|
||||
await page.getByTestId("new-event-type-dropdown").click();
|
||||
await page.getByTestId("option-team-1").click();
|
||||
// Expecting we can add a managed event type as team owner
|
||||
await expect(page.locator('button[value="MANAGED"]')).toBeVisible();
|
||||
|
||||
|
@ -46,51 +41,50 @@ test.describe("Managed Event Types tests", () => {
|
|||
await page.click('button[value="MANAGED"]');
|
||||
await page.fill("[name=title]", "managed");
|
||||
await page.click("[type=submit]");
|
||||
|
||||
await page.waitForURL("event-types/**");
|
||||
});
|
||||
|
||||
await test.step("Managed event type has unlocked fields for admin", async () => {
|
||||
await page.waitForSelector('[data-testid="update-eventtype"]');
|
||||
await page.getByTestId("update-eventtype").waitFor();
|
||||
await expect(page.locator('input[name="title"]')).toBeEditable();
|
||||
await expect(page.locator('input[name="slug"]')).toBeEditable();
|
||||
await expect(page.locator('input[name="length"]')).toBeEditable();
|
||||
await adminUser.logout();
|
||||
});
|
||||
|
||||
await test.step("Managed event type exists for added member", async () => {
|
||||
// Now we need to accept the invitation as member and come back in as admin to
|
||||
// assign the member in the managed event type
|
||||
await adminUser.logout();
|
||||
await memberUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
|
||||
await page.goto("/teams");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.locator('button[data-testid^="accept-invitation"]').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.getByText("Member").waitFor();
|
||||
|
||||
await memberUser.logout();
|
||||
|
||||
// Coming back as team owner to assign member user to managed event
|
||||
await adminUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.locator('[data-testid="event-types"] a[title="managed"]').click();
|
||||
await page.locator('[data-testid="vertical-tab-assignment"]').click();
|
||||
await page.getByTestId("event-types").locator('a[title="managed"]').click();
|
||||
await page.getByTestId("vertical-tab-assignment").click();
|
||||
await page.locator('[class$="control"]').filter({ hasText: "Select..." }).click();
|
||||
await page.locator("#react-select-5-option-1").click();
|
||||
await page.getByTestId(`select-option-${memberUser.id}`).click();
|
||||
await page.locator('[type="submit"]').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await adminUser.logout();
|
||||
await page.getByTestId("toast-success").waitFor();
|
||||
|
||||
// Coming back as member user to see if there is a managed event present after assignment
|
||||
await memberUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator('[data-testid="event-types"] a[title="managed"]')).toBeVisible();
|
||||
await adminUser.logout();
|
||||
});
|
||||
|
||||
await test.step("Managed event type has locked fields for added member", async () => {
|
||||
page.locator('[data-testid="event-types"] a[title="managed"]').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Coming back as member user to see if there is a managed event present after assignment
|
||||
await memberUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
|
||||
await page.getByTestId("event-types").locator('a[title="managed"]').click();
|
||||
await page.waitForURL("event-types/**");
|
||||
|
||||
await expect(page.locator('input[name="title"]')).not.toBeEditable();
|
||||
await expect(page.locator('input[name="slug"]')).not.toBeEditable();
|
||||
await expect(page.locator('input[name="length"]')).not.toBeEditable();
|
||||
|
|
|
@ -221,4 +221,23 @@ testBothBookers.describe("Reschedule Tests", async () => {
|
|||
expect(newBooking).not.toBeNull();
|
||||
expect(newBooking?.status).toBe(BookingStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
test("Attendee should be able to reschedule a booking", async ({ page, users, bookings }) => {
|
||||
const user = await users.create();
|
||||
const eventType = user.eventTypes[0];
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id);
|
||||
|
||||
// Go to attendee's reschedule link
|
||||
await page.goto(`/reschedule/${booking.uid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
|
||||
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking?.uid } });
|
||||
expect(newBooking).not.toBeNull();
|
||||
expect(newBooking?.status).toBe(BookingStatus.ACCEPTED);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1696,6 +1696,7 @@
|
|||
"switch_monthly": "Switch to monthly view",
|
||||
"switch_weekly": "Switch to weekly view",
|
||||
"switch_multiday": "Switch to day view",
|
||||
"switch_columnview": "Switch to column view",
|
||||
"num_locations": "{{num}} location options",
|
||||
"select_on_next_step": "Select on the next step",
|
||||
"this_meeting_has_not_started_yet": "This meeting has not started yet",
|
||||
|
|
|
@ -1694,6 +1694,7 @@
|
|||
"switch_monthly": "Passer en vue mensuelle",
|
||||
"switch_weekly": "Passer en vue hebdomadaire",
|
||||
"switch_multiday": "Passer en vue journalière",
|
||||
"switch_columnview": "Passer en vue colonne",
|
||||
"num_locations": "{{num}} options de lieu",
|
||||
"select_on_next_step": "Sélectionner à l'étape suivante",
|
||||
"this_meeting_has_not_started_yet": "Ce rendez-vous n'a pas encore commencé",
|
||||
|
|
|
@ -11,6 +11,14 @@
|
|||
"calcom_explained_new_user": "Completa la configurazione del tuo account {{appName}}! Mancano solo pochi passi per risolvere tutti i problemi di pianificazione.",
|
||||
"have_any_questions": "Hai domande? Siamo qui per aiutare.",
|
||||
"reset_password_subject": "{{appName}}: istruzioni per reimpostare la password",
|
||||
"verify_email_subject": "{{appName}}: verifica il tuo account",
|
||||
"check_your_email": "Controlla la tua e-mail",
|
||||
"verify_email_page_body": "Abbiamo inviato un'e-mail a {{email}}. È importante verificare il tuo indirizzo e-mail per garantire un migliore funzionamento dell'e-mail e del calendario {{appName}}.",
|
||||
"verify_email_banner_body": "Verifica il tuo indirizzo e-mail per garantire un migliore funzionamento dell'e-mail e del calendario {{appName}}.",
|
||||
"verify_email_email_header": "Verifica il tuo indirizzo e-mail",
|
||||
"verify_email_email_button": "Verifica l'e-mail",
|
||||
"verify_email_email_body": "Verifica il tuo indirizzo e-mail cliccando sul pulsante qui in basso.",
|
||||
"verify_email_email_link_text": "Ecco il link nel caso tu non voglia fare clic su pulsanti:",
|
||||
"event_declined_subject": "Rifiutato: {{title}} il {{date}}",
|
||||
"event_cancelled_subject": "Cancellato: {{title}} il {{date}}",
|
||||
"event_request_declined": "La tua richiesta per l'evento è stata rifiutata",
|
||||
|
|
|
@ -463,7 +463,7 @@ async function addAllTypesOfFieldsAndSaveForm(
|
|||
// Click on the field type dropdown.
|
||||
await page.locator(".data-testid-field-type").nth(nth).click();
|
||||
// Click on the dropdown option.
|
||||
await page.locator(`[data-testid="select-option-${fieldTypeLabel}"]`).click();
|
||||
await page.locator(`[data-testid^="select-option-"]`).filter({ hasText: fieldTypeLabel }).click();
|
||||
} else {
|
||||
// Set the identifier manually for the first field to test out a case when identifier isn't computed from label automatically
|
||||
// First field type is by default selected. So, no need to choose from dropdown
|
||||
|
|
|
@ -20,7 +20,7 @@ export function BookFormAsModal({ visible, onCancel }: { visible: boolean; onCan
|
|||
<DialogContent
|
||||
type={undefined}
|
||||
enableOverflow
|
||||
className="[&_.modalsticky]:border-t-subtle [&_.modalsticky]:bg-default dark:[&_.modalsticky]:bg-muted max-h-[80vh] pt-6 pb-0 [&_.modalsticky]:sticky [&_.modalsticky]:bottom-0 [&_.modalsticky]:left-0 [&_.modalsticky]:right-0 [&_.modalsticky]:-mx-8 [&_.modalsticky]:border-t [&_.modalsticky]:px-6 [&_.modalsticky]:py-4">
|
||||
className="[&_.modalsticky]:border-t-subtle [&_.modalsticky]:bg-default max-h-[80vh] pb-0 [&_.modalsticky]:sticky [&_.modalsticky]:bottom-0 [&_.modalsticky]:left-0 [&_.modalsticky]:right-0 [&_.modalsticky]:-mx-8 [&_.modalsticky]:border-t [&_.modalsticky]:px-8 [&_.modalsticky]:py-4">
|
||||
<h1 className="font-cal text-emphasis text-xl leading-5">{t("confirm_your_details")} </h1>
|
||||
<div className="my-4 flex space-x-2 rounded-md leading-none">
|
||||
<Badge variant="grayWithoutHover" startIcon={Calendar} size="lg">
|
||||
|
|
|
@ -122,7 +122,7 @@ const LayoutToggle = ({
|
|||
{
|
||||
value: BookerLayouts.COLUMN_VIEW,
|
||||
label: <Columns width="16" height="16" />,
|
||||
tooltip: t("switch_multiday"),
|
||||
tooltip: t("switch_columnview"),
|
||||
},
|
||||
].filter((layout) => enabledLayouts?.includes(layout.value as BookerLayouts));
|
||||
}, [t, enabledLayouts]);
|
||||
|
|
|
@ -83,6 +83,7 @@ export const AvailableTimes = ({
|
|||
key={slot.time}
|
||||
disabled={bookingFull}
|
||||
data-testid="time"
|
||||
data-disabled={bookingFull}
|
||||
data-time={slot.time}
|
||||
onClick={() => onTimeSelect(slot.time)}
|
||||
className="min-h-9 mb-2 flex h-auto w-full flex-col justify-center py-2"
|
||||
|
|
|
@ -12,7 +12,7 @@ export const TimeFormatToggle = () => {
|
|||
return (
|
||||
<ToggleGroup
|
||||
onValueChange={(newFormat) => {
|
||||
if (newFormat !== timeFormat) setTimeFormat(newFormat as TimeFormat);
|
||||
if (newFormat && newFormat !== timeFormat) setTimeFormat(newFormat as TimeFormat);
|
||||
}}
|
||||
defaultValue={timeFormat}
|
||||
value={timeFormat}
|
||||
|
|
|
@ -45,6 +45,12 @@ export const getCalEventResponses = ({
|
|||
if (!label) {
|
||||
throw new Error('Missing label for booking field "' + field.name + '"');
|
||||
}
|
||||
|
||||
// If the guests field is hidden (disableGuests is set on the event type) don't try and infer guests from attendees list
|
||||
if (field.name == "guests" && field.hidden) {
|
||||
backwardCompatibleResponses[field.name] = [];
|
||||
}
|
||||
|
||||
if (field.editable === "user" || field.editable === "user-readonly") {
|
||||
calEventUserFieldsResponses[field.name] = {
|
||||
label,
|
||||
|
|
|
@ -5,7 +5,7 @@ import { classNames } from "@calcom/lib";
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast, Switch } from "@calcom/ui";
|
||||
import { ArrowLeft, RotateCcw } from "@calcom/ui/components/icon";
|
||||
import { ArrowLeft, RotateCw } from "@calcom/ui/components/icon";
|
||||
|
||||
interface ICalendarSwitchProps {
|
||||
title: string;
|
||||
|
@ -92,7 +92,7 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => {
|
|||
{t("adding_events_to")}
|
||||
</span>
|
||||
)}
|
||||
{mutation.isLoading && <RotateCcw className="text-muted h-4 w-4 animate-spin ltr:ml-1 rtl:mr-1" />}
|
||||
{mutation.isLoading && <RotateCw className="text-muted h-4 w-4 animate-spin ltr:ml-1 rtl:mr-1" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -20,7 +20,7 @@ export function defaultCookies(useSecureCookies: boolean): CookiesOptions {
|
|||
const cookiePrefix = useSecureCookies ? "__Secure-" : "";
|
||||
|
||||
const defaultOptions: CookieOption["options"] = {
|
||||
domain: isENVDev ? "cal.local" : NEXTAUTH_COOKIE_DOMAIN,
|
||||
domain: isENVDev ? (process.env.ORGANIZATIONS_ENABLED ? "cal.local" : undefined) : NEXTAUTH_COOKIE_DOMAIN,
|
||||
// To enable cookies on widgets,
|
||||
// https://stackoverflow.com/questions/45094712/iframe-not-reading-cookies-in-chrome
|
||||
// But we need to set it as `lax` in development
|
||||
|
|
|
@ -61,10 +61,10 @@ export async function createContextInner(opts: CreateInnerContextOptions) {
|
|||
* Creates context for an incoming request
|
||||
* @link https://trpc.io/docs/context
|
||||
*/
|
||||
export const createContext = async ({ req, res }: CreateContextOptions) => {
|
||||
export const createContext = async ({ req, res }: CreateContextOptions, sessionGetter?: GetSessionFn) => {
|
||||
const locale = getLocaleFromHeaders(req);
|
||||
|
||||
const contextInner = await createContextInner({ locale });
|
||||
const session = !!sessionGetter ? await sessionGetter({ req, res }) : null;
|
||||
const contextInner = await createContextInner({ locale, session });
|
||||
return {
|
||||
...contextInner,
|
||||
req,
|
||||
|
|
|
@ -102,11 +102,18 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
|
|||
|
||||
export type UserFromSession = Awaited<ReturnType<typeof getUserFromSession>>;
|
||||
|
||||
const getUserSession = async (ctx: TRPCContextInner) => {
|
||||
const { getServerSession } = await import("@calcom/features/auth/lib/getServerSession");
|
||||
const getSession = async (ctx: TRPCContextInner) => {
|
||||
const { req, res } = ctx;
|
||||
const { getServerSession } = await import("@calcom/features/auth/lib/getServerSession");
|
||||
return req ? await getServerSession({ req, res }) : null;
|
||||
};
|
||||
|
||||
const session = req ? await getServerSession({ req, res }) : null;
|
||||
const getUserSession = async (ctx: TRPCContextInner) => {
|
||||
/**
|
||||
* It is possible that the session and user have already been added to the context by a previous middleware
|
||||
* or when creating the context
|
||||
*/
|
||||
const session = ctx.session || (await getSession(ctx));
|
||||
const user = session ? await getUserFromSession(ctx, session) : null;
|
||||
|
||||
return { user, session };
|
||||
|
|
|
@ -45,13 +45,39 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
|||
// Extract this from the input so it doesn't get saved in the db
|
||||
// eslint-disable-next-line
|
||||
userId,
|
||||
// eslint-disable-next-line
|
||||
teamId,
|
||||
bookingFields,
|
||||
offsetStart,
|
||||
...rest
|
||||
} = input;
|
||||
|
||||
const eventType = await ctx.prisma.eventType.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: {
|
||||
children: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
select: {
|
||||
workflowId: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (input.teamId && eventType.team?.id && input.teamId !== eventType.team.id) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const teamId = input.teamId || eventType.team?.id;
|
||||
|
||||
ensureUniqueBookingFields(bookingFields);
|
||||
|
||||
const data: Prisma.EventTypeUpdateInput = {
|
||||
|
@ -149,7 +175,23 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
|||
};
|
||||
}
|
||||
|
||||
if (hosts) {
|
||||
if (teamId && hosts) {
|
||||
// check if all hosts can be assigned (memberships that have accepted invite)
|
||||
const memberships =
|
||||
(await ctx.prisma.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
accepted: true,
|
||||
},
|
||||
})) || [];
|
||||
const teamMemberIds = memberships.map((membership) => membership.userId);
|
||||
// guard against missing IDs, this may mean a member has just been removed
|
||||
// or this request was forged.
|
||||
if (!hosts.every((host) => teamMemberIds.includes(host.userId))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
data.hosts = {
|
||||
deleteMany: {},
|
||||
create: hosts.map((host) => ({
|
||||
|
@ -256,48 +298,26 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
|||
});
|
||||
}
|
||||
}
|
||||
const [oldEventType, eventType] = await ctx.prisma.$transaction([
|
||||
ctx.prisma.eventType.findFirst({
|
||||
where: { id },
|
||||
select: {
|
||||
children: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
select: {
|
||||
workflowId: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ctx.prisma.eventType.update({
|
||||
where: { id },
|
||||
data,
|
||||
}),
|
||||
]);
|
||||
const updatedEventType = await ctx.prisma.eventType.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
// Handling updates to children event types (managed events types)
|
||||
await updateChildrenEventTypes({
|
||||
eventTypeId: id,
|
||||
currentUserId: ctx.user.id,
|
||||
oldEventType,
|
||||
oldEventType: eventType,
|
||||
hashedLink,
|
||||
connectedLink,
|
||||
updatedEventType: eventType,
|
||||
updatedEventType,
|
||||
children,
|
||||
prisma: ctx.prisma,
|
||||
});
|
||||
const res = ctx.res as NextApiResponse;
|
||||
if (typeof res?.revalidate !== "undefined") {
|
||||
try {
|
||||
await res?.revalidate(`/${ctx.user.username}/${eventType.slug}`);
|
||||
await res?.revalidate(`/${ctx.user.username}/${updatedEventType.slug}`);
|
||||
} catch (e) {
|
||||
// if reach this it is because the event type page has not been created, so it is not possible to revalidate it
|
||||
logger.debug((e as Error)?.message);
|
||||
|
|
|
@ -77,7 +77,7 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
|
|||
<DialogPrimitive.Content
|
||||
{...props}
|
||||
className={classNames(
|
||||
"fadeIn bg-default fixed left-1/2 top-1/2 z-50 w-full max-w-[22rem] -translate-x-1/2 -translate-y-1/2 rounded text-left shadow-xl focus-visible:outline-none sm:align-middle",
|
||||
"fadeIn bg-default fixed left-1/2 top-1/2 z-50 w-full max-w-[22rem] -translate-x-1/2 -translate-y-1/2 rounded-md text-left shadow-xl focus-visible:outline-none sm:align-middle",
|
||||
props.size == "xl"
|
||||
? "p-8 sm:max-w-[90rem]"
|
||||
: props.size == "lg"
|
||||
|
|
|
@ -43,8 +43,8 @@ export const OptionComponent = <
|
|||
// This gets styled in the select classNames prop now - handles overrides with styles vs className here doesnt
|
||||
<reactSelectComponents.Option {...props}>
|
||||
<div className="flex">
|
||||
<span className="mr-auto" data-testid={`select-option-${props.label}`}>
|
||||
{props.label}
|
||||
<span className="mr-auto" data-testid={`select-option-${(props as unknown as ExtendedOption).value}`}>
|
||||
{props.label || <> </>}
|
||||
</span>
|
||||
{(props.data as unknown as ExtendedOption).needsUpgrade && <UpgradeTeamsBadge />}
|
||||
{props.isSelected && <Check className="ml-2 h-4 w-4" />}
|
||||
|
|
|
@ -40,7 +40,7 @@ export const ToggleGroup = ({ options, onValueChange, isFullWidth, ...props }: T
|
|||
{...props}
|
||||
onValueChange={onValueChange}
|
||||
className={classNames(
|
||||
"min-h-9 bg-muted border-default relative inline-flex gap-0.5 rounded-md border p-1",
|
||||
"min-h-9 border-default relative inline-flex gap-0.5 rounded-md border p-1",
|
||||
props.className,
|
||||
isFullWidth && "w-full"
|
||||
)}>
|
||||
|
@ -50,7 +50,7 @@ export const ToggleGroup = ({ options, onValueChange, isFullWidth, ...props }: T
|
|||
disabled={option.disabled}
|
||||
value={option.value}
|
||||
className={classNames(
|
||||
"aria-checked:bg-subtle relative rounded-[4px] px-3 py-1 text-sm leading-tight transition-colors",
|
||||
"aria-checked:bg-emphasis relative rounded-[4px] px-3 py-1 text-sm leading-tight transition-colors",
|
||||
option.disabled
|
||||
? "text-gray-400 hover:cursor-not-allowed"
|
||||
: "text-default [&[aria-checked='false']]:hover:bg-emphasis",
|
||||
|
|
|
@ -279,6 +279,7 @@
|
|||
"INTERCOM_SECRET",
|
||||
"PROJECT_ID_VERCEL",
|
||||
"TEAM_ID_VERCEL",
|
||||
"AUTH_BEARER_TOKEN_VERCEL"
|
||||
"AUTH_BEARER_TOKEN_VERCEL",
|
||||
"ORGANIZATIONS_ENABLED"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user