Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
391 changes: 173 additions & 218 deletions apps/web/components/booking/BookingListItem.tsx

Large diffs are not rendered by default.

462 changes: 462 additions & 0 deletions apps/web/components/booking/bookingActions.test.ts

Large diffs are not rendered by default.

243 changes: 243 additions & 0 deletions apps/web/components/booking/bookingActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import type { ActionType } from "@calcom/ui/components/table";

import type { BookingItemProps } from "./BookingListItem";

export interface BookingActionContext {
booking: BookingItemProps;
isUpcoming: boolean;
isOngoing: boolean;
isBookingInPast: boolean;
isCancelled: boolean;
isConfirmed: boolean;
isRejected: boolean;
isPending: boolean;
isRescheduled: boolean;
isRecurring: boolean;
isTabRecurring: boolean;
isTabUnconfirmed: boolean;
isBookingFromRoutingForm: boolean;
isDisabledCancelling: boolean;
isDisabledRescheduling: boolean;
isCalVideoLocation: boolean;
showPendingPayment: boolean;
cardCharged: boolean;
attendeeList: Array<{
name: string;
email: string;
id: number;
noShow: boolean;
phoneNumber: string | null;
}>;
getSeatReferenceUid: () => string | undefined;
t: (key: string) => string;
}

export function getPendingActions(context: BookingActionContext): ActionType[] {
const { booking, isPending, isTabRecurring, isTabUnconfirmed, isRecurring, showPendingPayment, t } =
context;

const actions: ActionType[] = [
{
id: "reject",
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
icon: "ban",
disabled: false, // This would be controlled by mutation state in the component
},
];

// For bookings with payment, only confirm if the booking is paid for
// Original logic: (isPending && !paymentAppData.enabled) || (paymentAppData.enabled && !!paymentAppData.price && booking.paid)
if ((isPending && !showPendingPayment) || (showPendingPayment && booking.paid)) {
actions.push({
id: "confirm",
bookingId: booking.id,
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
icon: "check" as const,
disabled: false, // This would be controlled by mutation state in the component
});
}

return actions;
}

export function getCancelEventAction(context: BookingActionContext): ActionType {
const { booking, isTabRecurring, isRecurring, getSeatReferenceUid, t } = context;

return {
id: "cancel",
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event"),
href: `/booking/${booking.uid}?cancel=true${
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
}${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""}`,
icon: "circle-x",
color: "destructive",
disabled: isActionDisabled("cancel", context),
};
}

export function getVideoOptionsActions(context: BookingActionContext): ActionType[] {
const { booking, isBookingInPast, isConfirmed, isCalVideoLocation, t } = context;

return [
{
id: "view_recordings",
label: t("view_recordings"),
icon: "video",
disabled: !(isBookingInPast && isConfirmed && isCalVideoLocation && booking.isRecorded),
},
{
id: "meeting_session_details",
label: t("view_session_details"),
icon: "info",
disabled: !(isBookingInPast && isConfirmed && isCalVideoLocation),
},
];
}

export function getEditEventActions(context: BookingActionContext): ActionType[] {
const {
booking,
isBookingInPast,
isDisabledRescheduling,
isBookingFromRoutingForm,
getSeatReferenceUid,
t,
} = context;

const actions: (ActionType | null)[] = [
{
id: "reschedule",
icon: "clock",
label: t("reschedule_booking"),
href: `/reschedule/${booking.uid}${
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
}`,
disabled:
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
},
{
id: "reschedule_request",
icon: "send",
iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ",
label: t("send_reschedule_request"),
disabled:
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
},
isBookingFromRoutingForm
? {
id: "reroute",
label: t("reroute"),
icon: "waypoints",
disabled: false,
}
: null,
{
id: "change_location",
label: t("edit_location"),
icon: "map-pin",
disabled: false,
},
booking.eventType?.disableGuests
? null
: {
id: "add_members",
label: t("additional_guests"),
icon: "user-plus",
disabled: false,
},
// Reassign (if round robin)
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN
? {
id: "reassign",
label: t("reassign"),
icon: "users",
disabled: false,
}
: null,
];

return actions.filter(Boolean) as ActionType[];
}

export function getAfterEventActions(context: BookingActionContext): ActionType[] {
const { booking, cardCharged, attendeeList, t } = context;

const actions: (ActionType | null)[] = [
...getVideoOptionsActions(context),
booking.status === BookingStatus.ACCEPTED && booking.paid && booking.payment[0]?.paymentOption === "HOLD"
? {
id: "charge_card",
label: cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee"),
icon: "credit-card",
disabled: cardCharged,
}
: null,
{
id: "no_show",
label:
attendeeList.length === 1 && attendeeList[0].noShow ? t("unmark_as_no_show") : t("mark_as_no_show"),
icon: attendeeList.length === 1 && attendeeList[0].noShow ? "eye" : "eye-off",
disabled: false, // This would be controlled by booking state in the component
},
];

return actions.filter(Boolean) as ActionType[];
}

export function shouldShowPendingActions(context: BookingActionContext): boolean {
const { isPending, isUpcoming, isCancelled } = context;
return isPending && isUpcoming && !isCancelled;
}

export function shouldShowEditActions(context: BookingActionContext): boolean {
const { isPending, isTabRecurring, isRecurring, isCancelled } = context;
return !isPending && !(isTabRecurring && isRecurring) && !isCancelled;
}

export function shouldShowRecurringCancelAction(context: BookingActionContext): boolean {
const { isTabRecurring, isRecurring } = context;
return isTabRecurring && isRecurring;
}

export function isActionDisabled(actionId: string, context: BookingActionContext): boolean {
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isPending, isConfirmed } =
context;

switch (actionId) {
case "reschedule":
case "reschedule_request":
return (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling;
case "cancel":
return isDisabledCancelling || (isBookingInPast && isPending && !isConfirmed);
case "view_recordings":
return !(isBookingInPast && booking.status === BookingStatus.ACCEPTED && context.isCalVideoLocation);
case "meeting_session_details":
return !(isBookingInPast && booking.status === BookingStatus.ACCEPTED && context.isCalVideoLocation);
case "charge_card":
return context.cardCharged;
default:
return false;
}
}

export function getActionLabel(actionId: string, context: BookingActionContext): string {
const { booking, isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context;

switch (actionId) {
case "reject":
return (isTabRecurring || context.isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject");
case "confirm":
return (isTabRecurring || context.isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm");
case "cancel":
return isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event");
case "no_show":
return attendeeList.length === 1 && attendeeList[0].noShow
? t("unmark_as_no_show")
: t("mark_as_no_show");
case "charge_card":
return cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee");
default:
return t(actionId);
}
}
9 changes: 8 additions & 1 deletion apps/web/playwright/booking-pages.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ test.describe("pro user", () => {
await pro.apiLogin();
await page.goto("/bookings/upcoming");
await page.waitForSelector('[data-testid="bookings"]');
await page.locator('[data-testid="edit_booking"]').nth(0).click();
// Click the ellipsis menu button to open the dropdown
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
await page.locator('[data-testid="reschedule"]').click();
await page.waitForURL((url) => {
const bookingId = url.searchParams.get("rescheduleUid");
Expand Down Expand Up @@ -205,6 +206,9 @@ test.describe("pro user", () => {
await pro.apiLogin();

await page.goto("/bookings/upcoming");
// Click the ellipsis menu button to open the dropdown
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
// Click the cancel option in the dropdown
await page.locator('[data-testid="cancel"]').click();
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking/");
Expand Down Expand Up @@ -237,6 +241,9 @@ test.describe("pro user", () => {
await pro.apiLogin();

await page.goto("/bookings/upcoming");
// Click the ellipsis menu button to open the dropdown
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
// Click the cancel option in the dropdown
await page.locator('[data-testid="cancel"]').click();
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking/");
Expand Down
5 changes: 4 additions & 1 deletion apps/web/playwright/bookings-list.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,10 @@ test.describe("Bookings", () => {
.locator(`[data-testid="select-filter-options-userId"] [role="option"]:has-text("${thirdUser.name}")`)
.click();
await bookingsGetResponse2;
await expect(page.locator('text="Cancel event"').nth(0)).toBeVisible();
// Click the ellipsis menu button to open the dropdown
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
// Check that the cancel option is visible in the dropdown
await expect(page.locator('[data-testid="cancel"]')).toBeVisible();

//expect only 3 bookings (out of 4 total) to be shown in list.
//where ThirdUser is either organizer or attendee
Expand Down
7 changes: 6 additions & 1 deletion apps/web/playwright/dynamic-booking-pages.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ test("dynamic booking", async ({ page, users }) => {
await test.step("can reschedule a booking", async () => {
// Logged in
await page.goto("/bookings/upcoming");
await page.locator('[data-testid="edit_booking"]').nth(0).click();
// Click the ellipsis menu button to open the dropdown
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
// Click the reschedule option in the dropdown
await page.locator('[data-testid="reschedule"]').click();
await page.waitForURL((url) => {
const bookingId = url.searchParams.get("rescheduleUid");
Expand All @@ -54,6 +56,9 @@ test("dynamic booking", async ({ page, users }) => {

await test.step("Can cancel the recently created booking", async () => {
await page.goto("/bookings/upcoming");
// Click the ellipsis menu button to open the dropdown
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
// Click the cancel option in the dropdown
await page.locator('[data-testid="cancel"]').click();
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking");
Expand Down
16 changes: 11 additions & 5 deletions apps/web/playwright/reschedule.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ test.describe("Reschedule Tests", async () => {
await user.apiLogin();
await page.goto("/bookings/upcoming");

await page.locator('[data-testid="edit_booking"]').nth(0).click();
// Click the ellipsis menu button to open the dropdown
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();

await page.locator('[data-testid="reschedule_request"]').click();

Expand Down Expand Up @@ -75,7 +76,8 @@ test.describe("Reschedule Tests", async () => {
await user.apiLogin();
await page.goto("/bookings/past");

await page.locator('[data-testid="edit_booking"]').nth(0).click();
// Click the ellipsis menu button to open the dropdown
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();

await expect(page.locator('[data-testid="reschedule"]')).toBeVisible();
await expect(page.locator('[data-testid="reschedule_request"]')).toBeVisible();
Expand All @@ -91,10 +93,14 @@ test.describe("Reschedule Tests", async () => {

await page.reload();

await page.locator('[data-testid="edit_booking"]').nth(0).click();
// Click the ellipsis menu button to open the dropdown
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();

await expect(page.locator('[data-testid="reschedule"]')).toBeHidden();
await expect(page.locator('[data-testid="reschedule_request"]')).toBeHidden();
// Check that the reschedule options are visible but disabled
await expect(page.locator('[data-testid="reschedule"]')).toBeVisible();
await expect(page.locator('[data-testid="reschedule_request"]')).toBeVisible();
await expect(page.locator('[data-testid="reschedule"]')).toBeDisabled();
await expect(page.locator('[data-testid="reschedule_request"]')).toBeDisabled();
});

test("Should display former time when rescheduling availability", async ({ page, users, bookings }) => {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
"enable_automatic_transcription": "Enable automatic transcription after joining the meeting",
"enable_automatic_recording": "Enable automatic recording after organizer joins the meeting",
"video_options": "Video Options",
"get_meeting_session_details": "Get Meeting Session Details",
"edit_event": "Edit event",
"view_session_details": "View Session Details",
"meeting_session_details": "Meeting Session Details",
"meeting_session": "Meeting Session",
"session_id": "Session ID",
Expand Down
Loading