Merge branch 'main' into feat/organizations

This commit is contained in:
Leo Giovanetti 2023-06-10 15:23:41 -03:00
commit 3c3e834a44
30 changed files with 291 additions and 122 deletions

View File

@ -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={

View File

@ -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

View File

@ -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);

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "2.9.7",
"version": "2.9.8",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",

View File

@ -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

View File

@ -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;
}

View File

@ -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);

View File

@ -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");

View File

@ -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();
});
});

View File

@ -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 } }),
};
};

View File

@ -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);

View File

@ -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) {

View File

@ -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();

View File

@ -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);
});
});

View File

@ -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",

View File

@ -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é",

View File

@ -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",

View File

@ -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

View File

@ -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">

View File

@ -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]);

View File

@ -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"

View File

@ -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}

View File

@ -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,

View File

@ -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>
);
};

View File

@ -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,

View File

@ -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 };

View File

@ -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);

View File

@ -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"

View File

@ -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 || <>&nbsp;</>}
</span>
{(props.data as unknown as ExtendedOption).needsUpgrade && <UpgradeTeamsBadge />}
{props.isSelected && <Check className="ml-2 h-4 w-4" />}

View File

@ -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",