refactor: Booking list items actions UI (#22540)

* refactor: Booking list items actions UI

* chore: save

* fix: UI

* fix: logic and remove unused

* chore: change name

* Update apps/web/components/booking/BookingListItem.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* perf: refactor

* fix: update tests

* chore: update reschedule test

* refactor: create bookingActions and BookingListItem

* tests: add unit tests for booking actions

* fix: logic

* fix: cancel button regresso

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Udit Takkar
2025-07-23 15:02:12 +05:30
committed by GitHub
co-authored by coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
parent ee9699e267
commit 16a7bb7bc5
8 changed files with 907 additions and 225 deletions
+171 -216
View File
@@ -18,7 +18,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
@@ -41,8 +41,8 @@ import {
import { TextAreaField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { MeetingTimeInTimezones } from "@calcom/ui/components/popover";
import type { ActionType } from "@calcom/ui/components/table";
import { TableActions } from "@calcom/ui/components/table";
import type { ActionType } from "@calcom/ui/components/table";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
@@ -55,11 +55,22 @@ import { ReassignDialog } from "@components/dialog/ReassignDialog";
import { RerouteDialog } from "@components/dialog/RerouteDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
import {
getPendingActions,
getCancelEventAction,
getEditEventActions,
getAfterEventActions,
shouldShowPendingActions,
shouldShowEditActions,
shouldShowRecurringCancelAction,
type BookingActionContext,
} from "./bookingActions";
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
type BookingItemProps = BookingItem & {
export type BookingItemProps = BookingItem & {
listingStatus: BookingListingStatus;
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined;
loggedInUser: {
@@ -191,6 +202,9 @@ function BookingListItem(booking: BookingItemProps) {
);
const provider = guessEventLocationType(location);
const isDisabledCancelling = booking.eventType.disableCancelling;
const isDisabledRescheduling = booking.eventType.disableRescheduling;
const bookingConfirm = async (confirm: boolean) => {
let body = {
bookingId: booking.id,
@@ -214,174 +228,44 @@ function BookingListItem(booking: BookingItemProps) {
return booking.seatsReferences[0].referenceUid;
};
const pendingActions: ActionType[] = [
{
id: "reject",
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
onClick: () => {
setRejectionDialogIsOpen(true);
},
icon: "ban",
disabled: mutation.isPending,
},
// For bookings with payment, only confirm if the booking is paid for
...((isPending && !paymentAppData.enabled) ||
(paymentAppData.enabled && !!paymentAppData.price && booking.paid)
? [
{
id: "confirm",
bookingId: booking.id,
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
onClick: () => {
bookingConfirm(true);
},
icon: "check" as const,
disabled: mutation.isPending,
},
]
: []),
];
const actionContext: BookingActionContext = {
booking,
isUpcoming,
isOngoing,
isBookingInPast,
isCancelled,
isConfirmed,
isRejected,
isPending,
isRescheduled,
isRecurring,
isTabRecurring,
isTabUnconfirmed,
isBookingFromRoutingForm,
isDisabledCancelling,
isDisabledRescheduling,
isCalVideoLocation:
!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "",
showPendingPayment: paymentAppData.enabled && booking.payment.length && !booking.paid,
cardCharged,
attendeeList,
getSeatReferenceUid,
t,
} as BookingActionContext;
const editBookingActions: ActionType[] = [
...(isBookingInPast && !booking.eventType.allowReschedulingPastBookings
? []
: [
{
id: "reschedule",
icon: "clock" as const,
label: t("reschedule_booking"),
href: `/reschedule/${booking.uid}${
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
}`,
},
{
id: "reschedule_request",
icon: "send" as const,
iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ",
label: t("send_reschedule_request"),
onClick: () => {
setIsOpenRescheduleDialog(true);
},
},
]),
...(isBookingFromRoutingForm
? [
{
id: "reroute",
label: t("reroute"),
onClick: () => {
setRerouteDialogIsOpen(true);
},
icon: "waypoints" as const,
},
]
: []),
{
id: "change_location",
label: t("edit_location"),
onClick: () => {
setIsOpenLocationDialog(true);
},
icon: "map-pin" as const,
},
...(booking.eventType?.disableGuests
? []
: [
{
id: "add_members",
label: t("additional_guests"),
onClick: () => {
setIsOpenAddGuestsDialog(true);
},
icon: "user-plus" as const,
},
]),
];
const basePendingActions = getPendingActions(actionContext);
const pendingActions: ActionType[] = basePendingActions.map((action) => ({
...action,
onClick:
action.id === "reject"
? () => setRejectionDialogIsOpen(true)
: action.id === "confirm"
? () => bookingConfirm(true)
: undefined,
disabled: action.disabled || mutation.isPending,
})) as ActionType[];
if (booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
editBookingActions.push({
id: "reassign ",
label: t("reassign"),
onClick: () => {
setIsOpenReassignDialog(true);
},
icon: "users" as const,
});
}
if (isBookingInPast || isOngoing) {
editBookingActions.push({
id: "no_show",
label:
attendeeList.length === 1 && attendeeList[0].noShow ? t("unmark_as_no_show") : t("mark_as_no_show"),
onClick: () => {
// If there's only one attendee, mark them as no-show directly without showing the dialog
if (attendeeList.length === 1) {
const attendee = attendeeList[0];
noShowMutation.mutate({
bookingUid: booking.uid,
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
});
return;
}
setIsNoShowDialogOpen(true);
},
icon: attendeeList.length === 1 && attendeeList[0].noShow ? "eye" : ("eye-off" as const),
});
}
let bookedActions: ActionType[] = [
{
id: "cancel",
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event"),
/* When cancelling we need to let the UI and the API know if the intention is to
cancel all remaining bookings or just that booking instance. */
href: `/booking/${booking.uid}?cancel=true${
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
}${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""}
`,
icon: "x" as const,
},
{
id: "edit_booking",
label: t("edit"),
actions: editBookingActions,
},
];
const chargeCardActions: ActionType[] = [
{
id: "charge_card",
label: cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee"),
disabled: cardCharged,
onClick: () => {
setChargeCardDialogIsOpen(true);
},
icon: "credit-card" as const,
},
];
const isDisabledCancelling = booking.eventType.disableCancelling;
const isDisabledRescheduling = booking.eventType.disableRescheduling;
if (isTabRecurring && isRecurring) {
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
}
if (isDisabledCancelling || (isBookingInPast && isPending && !isConfirmed)) {
bookedActions = bookedActions.filter((action) => action.id !== "cancel");
}
if (isDisabledRescheduling) {
bookedActions.forEach((action) => {
if (action.id === "edit_booking") {
action.actions = action.actions?.filter(
({ id }) => id !== "reschedule" && id !== "reschedule_request"
);
}
});
}
const cancelEventAction = getCancelEventAction(actionContext);
const RequestSentMessage = () => {
return (
@@ -460,38 +344,57 @@ function BookingListItem(booking: BookingItemProps) {
const title = booking.title;
const showViewRecordingsButton = !!(booking.isRecorded && isBookingInPast && isConfirmed);
const showCheckRecordingButton =
isBookingInPast &&
isConfirmed &&
!booking.isRecorded &&
(!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "");
const isCalVideoLocation =
!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "";
const videoOptionsActions: ActionType[] = [
{
id: "view_recordings",
label: showCheckRecordingButton ? t("check_for_recordings") : t("view_recordings"),
onClick: () => {
setViewRecordingsDialogIsOpen(true);
},
color: showCheckRecordingButton ? "secondary" : "primary",
disabled: mutation.isPending,
},
{
id: "meeting_session_details",
label: t("get_meeting_session_details"),
onClick: () => {
setMeetingSessionDetailsDialogIsOpen(true);
},
disabled: mutation.isPending,
},
];
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
const baseEditEventActions = getEditEventActions(actionContext);
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
...action,
onClick:
action.id === "reschedule_request"
? () => setIsOpenRescheduleDialog(true)
: action.id === "reroute"
? () => setRerouteDialogIsOpen(true)
: action.id === "change_location"
? () => setIsOpenLocationDialog(true)
: action.id === "add_members"
? () => setIsOpenAddGuestsDialog(true)
: action.id === "reassign"
? () => setIsOpenReassignDialog(true)
: undefined,
})) as ActionType[];
const baseAfterEventActions = getAfterEventActions(actionContext);
const afterEventActions: ActionType[] = baseAfterEventActions.map((action) => ({
...action,
onClick:
action.id === "view_recordings"
? () => setViewRecordingsDialogIsOpen(true)
: action.id === "meeting_session_details"
? () => setMeetingSessionDetailsDialogIsOpen(true)
: action.id === "charge_card"
? () => setChargeCardDialogIsOpen(true)
: action.id === "no_show"
? () => {
if (attendeeList.length === 1) {
const attendee = attendeeList[0];
noShowMutation.mutate({
bookingUid: booking.uid,
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
});
return;
}
setIsNoShowDialogOpen(true);
}
: undefined,
disabled:
action.disabled ||
(action.id === "no_show" && !(isBookingInPast || isOngoing)) ||
(action.id === "view_recordings" && !booking.isRecorded),
})) as ActionType[];
return (
<>
<RescheduleDialog
@@ -726,32 +629,84 @@ function BookingListItem(booking: BookingItemProps) {
</Link>
</div>
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
{isUpcoming && !isCancelled ? (
<>
{isPending && <TableActions actions={pendingActions} />}
{isConfirmed && <TableActions actions={bookedActions} />}
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
</>
) : null}
{isBookingInPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
{isBookingInPast && isConfirmed ? <TableActions actions={bookedActions} /> : null}
{isCalVideoLocation && (
<TableActions
actions={[{ id: "video_options", label: t("video_options"), actions: videoOptionsActions }]}
/>
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
{shouldShowEditActions(actionContext) && (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
type="button"
color="secondary"
variant="icon"
StartIcon="ellipsis"
data-testid="booking-actions-dropdown"
/>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
{editEventActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
<DropdownItem
type="button"
color={action.color}
StartIcon={action.icon}
href={action.href}
disabled={action.disabled}
onClick={action.onClick}
data-bookingid={action.bookingId}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
</DropdownItem>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
{afterEventActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
<DropdownItem
type="button"
color={action.color}
StartIcon={action.icon}
href={action.href}
onClick={action.onClick}
disabled={action.disabled}
data-bookingid={action.bookingId}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
</DropdownItem>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
className="rounded-lg"
key={cancelEventAction.id}
disabled={cancelEventAction.disabled}>
<DropdownItem
type="button"
color={cancelEventAction.color}
StartIcon={cancelEventAction.icon}
href={cancelEventAction.href}
onClick={cancelEventAction.onClick}
disabled={cancelEventAction.disabled}
data-bookingid={cancelEventAction.bookingId}
data-testid={cancelEventAction.id}
className={cancelEventAction.disabled ? "text-muted" : undefined}>
{cancelEventAction.label}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
)}
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
{isCancelled && booking.rescheduled && (
<div className="hidden h-full items-center md:flex">
<RequestSentMessage />
</div>
)}
{booking.status === "ACCEPTED" &&
booking.paid &&
booking.payment[0]?.paymentOption === "HOLD" && (
<div className="ml-2">
<TableActions actions={chargeCardActions} />
</div>
)}
</div>
</div>
<BookingItemBadges
@@ -0,0 +1,462 @@
import { describe, it, expect } from "vitest";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import {
getPendingActions,
getCancelEventAction,
getVideoOptionsActions,
getEditEventActions,
getAfterEventActions,
shouldShowPendingActions,
shouldShowEditActions,
shouldShowRecurringCancelAction,
isActionDisabled,
getActionLabel,
type BookingActionContext,
} from "./bookingActions";
const mockT = (key: string) => key;
function createMockContext(overrides: Partial<BookingActionContext> = {}): BookingActionContext {
const now = new Date();
const startTime = new Date(now.getTime() + 24 * 60 * 60 * 1000); // Tomorrow
const endTime = new Date(startTime.getTime() + 60 * 60 * 1000); // 1 hour later
return {
booking: {
id: 1,
uid: "booking-123",
title: "Test Meeting",
description: "Test meeting description",
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
createdAt: now,
updatedAt: now,
status: BookingStatus.ACCEPTED,
paid: false,
fromReschedule: null,
recurringEventId: null,
rescheduled: false,
isRecorded: false,
rescheduler: null,
userPrimaryEmail: "organizer@example.com",
customInputs: {},
responses: {},
references: [],
attendees: [
{
id: 1,
name: "John Doe",
email: "john@example.com",
timeZone: "America/New_York",
phoneNumber: null,
locale: "en",
bookingId: 1,
noShow: false,
} as any,
],
user: {
id: 1,
name: "Organizer",
email: "organizer@example.com",
},
eventType: {
id: 1,
title: "Test Event Type",
slug: "test-event",
length: 60,
schedulingType: SchedulingType.COLLECTIVE,
team: null,
eventTypeColor: {
lightEventTypeColor: "#000000",
darkEventTypeColor: "#ffffff",
},
disableCancelling: false,
disableRescheduling: false,
disableGuests: false,
allowReschedulingPastBookings: false,
recurringEvent: null,
price: 0,
currency: "usd",
metadata: {},
},
location: "integrations:daily",
payment: [],
seatsReferences: [],
assignmentReason: [],
metadata: null,
routedFromRoutingFormReponse: null,
listingStatus: "upcoming",
recurringInfo: undefined,
loggedInUser: {
userId: 1,
userTimeZone: "America/New_York",
userTimeFormat: 12,
userEmail: "organizer@example.com",
},
isToday: false,
},
isUpcoming: true,
isOngoing: false,
isBookingInPast: false,
isCancelled: false,
isConfirmed: true,
isRejected: false,
isPending: false,
isRescheduled: false,
isRecurring: false,
isTabRecurring: false,
isTabUnconfirmed: false,
isBookingFromRoutingForm: false,
isDisabledCancelling: false,
isDisabledRescheduling: false,
isCalVideoLocation: true,
showPendingPayment: false,
cardCharged: false,
attendeeList: [
{
name: "John Doe",
email: "john@example.com",
id: 1,
noShow: false,
phoneNumber: null,
},
],
getSeatReferenceUid: () => undefined,
t: mockT,
...overrides,
} as BookingActionContext;
}
describe("Booking Actions", () => {
describe("getPendingActions", () => {
it("should return reject action for pending booking", () => {
const context = createMockContext({ isPending: true });
const actions = getPendingActions(context);
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
id: "reject",
label: "reject",
icon: "ban",
disabled: false,
});
expect(actions[1]).toEqual({
id: "confirm",
bookingId: 1,
label: "confirm",
icon: "check",
disabled: false,
});
});
it("should return reject action only for non-pending booking", () => {
const context = createMockContext({ isPending: false });
const actions = getPendingActions(context);
expect(actions).toHaveLength(1);
expect(actions[0].id).toBe("reject");
});
it("should use correct labels for recurring bookings", () => {
const context = createMockContext({
isPending: true,
isRecurring: true,
isTabRecurring: true,
});
const actions = getPendingActions(context);
expect(actions[0].label).toBe("reject_all");
expect(actions[1].label).toBe("confirm_all");
});
});
describe("getCancelEventAction", () => {
it("should return cancel action with correct properties", () => {
const context = createMockContext();
const action = getCancelEventAction(context);
expect(action).toEqual({
id: "cancel",
label: "cancel_event",
href: "/booking/booking-123?cancel=true",
icon: "circle-x",
color: "destructive",
disabled: false,
});
});
it("should be disabled when cancellation is disabled", () => {
const context = createMockContext({ isDisabledCancelling: true });
const action = getCancelEventAction(context);
expect(action.disabled).toBe(true);
});
it("should be disabled for past pending bookings", () => {
const context = createMockContext({
isBookingInPast: true,
isPending: true,
isConfirmed: false,
});
const action = getCancelEventAction(context);
expect(action.disabled).toBe(true);
});
it("should include recurring parameters for recurring bookings", () => {
const context = createMockContext({
isRecurring: true,
isTabRecurring: true,
});
const action = getCancelEventAction(context);
expect(action.href).toContain("allRemainingBookings=true");
expect(action.label).toBe("cancel_all_remaining");
});
});
describe("getVideoOptionsActions", () => {
it("should return video actions for past confirmed bookings", () => {
const context = createMockContext({
isBookingInPast: true,
isConfirmed: true,
isCalVideoLocation: true,
booking: {
...createMockContext().booking,
isRecorded: true,
},
});
const actions = getVideoOptionsActions(context);
expect(actions).toHaveLength(2);
expect(actions[0].id).toBe("view_recordings");
expect(actions[1].id).toBe("meeting_session_details");
expect(actions[0].disabled).toBe(false);
expect(actions[1].disabled).toBe(false);
});
it("should disable video actions for upcoming bookings", () => {
const context = createMockContext({ isBookingInPast: false });
const actions = getVideoOptionsActions(context);
expect(actions[0].disabled).toBe(true);
expect(actions[1].disabled).toBe(true);
});
it("should disable video actions for non-Cal video locations", () => {
const context = createMockContext({
isBookingInPast: true,
isConfirmed: true,
isCalVideoLocation: false,
});
const actions = getVideoOptionsActions(context);
expect(actions[0].disabled).toBe(true);
expect(actions[1].disabled).toBe(true);
});
});
describe("getEditEventActions", () => {
it("should return basic edit actions", () => {
const context = createMockContext();
const actions = getEditEventActions(context);
const actionIds = actions.map((a) => a.id);
expect(actionIds).toContain("reschedule");
expect(actionIds).toContain("reschedule_request");
expect(actionIds).toContain("change_location");
expect(actionIds).toContain("add_members");
});
it("should include reroute action for routing form bookings", () => {
const context = createMockContext({ isBookingFromRoutingForm: true });
const actions = getEditEventActions(context);
const rerouteAction = actions.find((a) => a.id === "reroute");
expect(rerouteAction).toBeDefined();
});
it("should include reassign action for round robin events", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.ROUND_ROBIN,
},
},
});
const actions = getEditEventActions(context);
const reassignAction = actions.find((a) => a.id === "reassign");
expect(reassignAction).toBeDefined();
});
it("should exclude add_members when guests are disabled", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
disableGuests: true,
},
},
});
const actions = getEditEventActions(context);
const addMembersAction = actions.find((a) => a.id === "add_members");
expect(addMembersAction).toBeUndefined();
});
it("should disable reschedule actions when rescheduling is disabled", () => {
const context = createMockContext({ isDisabledRescheduling: true });
const actions = getEditEventActions(context);
const rescheduleAction = actions.find((a) => a.id === "reschedule");
const rescheduleRequestAction = actions.find((a) => a.id === "reschedule_request");
expect(rescheduleAction?.disabled).toBe(true);
expect(rescheduleRequestAction?.disabled).toBe(true);
});
});
describe("getAfterEventActions", () => {
it("should include video actions and no-show action", () => {
const context = createMockContext({ isBookingInPast: true, isConfirmed: true });
const actions = getAfterEventActions(context);
const actionIds = actions.map((a) => a.id);
expect(actionIds).toContain("view_recordings");
expect(actionIds).toContain("meeting_session_details");
expect(actionIds).toContain("no_show");
});
it("should include charge card action for held payments", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
status: BookingStatus.ACCEPTED,
paid: true,
payment: [{ paymentOption: "HOLD", amount: 1000, currency: "usd", success: true }],
},
});
const actions = getAfterEventActions(context);
const chargeCardAction = actions.find((a) => a.id === "charge_card");
expect(chargeCardAction).toBeDefined();
});
it("should show correct no-show label for single attendee", () => {
const context = createMockContext({
attendeeList: [{ name: "John", email: "john@example.com", id: 1, noShow: true, phoneNumber: null }],
});
const actions = getAfterEventActions(context);
const noShowAction = actions.find((a) => a.id === "no_show");
expect(noShowAction?.label).toBe("unmark_as_no_show");
expect(noShowAction?.icon).toBe("eye");
});
});
describe("shouldShowPendingActions", () => {
it("should return true for pending upcoming bookings", () => {
const context = createMockContext({ isPending: true, isUpcoming: true, isCancelled: false });
expect(shouldShowPendingActions(context)).toBe(true);
});
it("should return false for cancelled bookings", () => {
const context = createMockContext({ isPending: true, isUpcoming: true, isCancelled: true });
expect(shouldShowPendingActions(context)).toBe(false);
});
it("should return false for past bookings", () => {
const context = createMockContext({ isPending: true, isUpcoming: false, isCancelled: false });
expect(shouldShowPendingActions(context)).toBe(false);
});
});
describe("shouldShowEditActions", () => {
it("should return true for confirmed upcoming bookings", () => {
const context = createMockContext({ isPending: false, isCancelled: false });
expect(shouldShowEditActions(context)).toBe(true);
});
it("should return false for pending bookings", () => {
const context = createMockContext({ isPending: true });
expect(shouldShowEditActions(context)).toBe(false);
});
it("should return false for cancelled bookings", () => {
const context = createMockContext({ isCancelled: true });
expect(shouldShowEditActions(context)).toBe(false);
});
});
describe("shouldShowRecurringCancelAction", () => {
it("should return true for recurring bookings in recurring tab", () => {
const context = createMockContext({ isTabRecurring: true, isRecurring: true });
expect(shouldShowRecurringCancelAction(context)).toBe(true);
});
it("should return false for non-recurring bookings", () => {
const context = createMockContext({ isTabRecurring: true, isRecurring: false });
expect(shouldShowRecurringCancelAction(context)).toBe(false);
});
});
describe("isActionDisabled", () => {
it("should disable reschedule actions when rescheduling is disabled", () => {
const context = createMockContext({ isDisabledRescheduling: true });
expect(isActionDisabled("reschedule", context)).toBe(true);
expect(isActionDisabled("reschedule_request", context)).toBe(true);
});
it("should disable cancel action when cancellation is disabled", () => {
const context = createMockContext({ isDisabledCancelling: true });
expect(isActionDisabled("cancel", context)).toBe(true);
});
it("should disable video actions for non-past bookings", () => {
const context = createMockContext({ isBookingInPast: false });
expect(isActionDisabled("view_recordings", context)).toBe(true);
expect(isActionDisabled("meeting_session_details", context)).toBe(true);
});
it("should disable charge card action when already charged", () => {
const context = createMockContext({ cardCharged: true });
expect(isActionDisabled("charge_card", context)).toBe(true);
});
});
describe("getActionLabel", () => {
it("should return correct labels for different actions", () => {
const context = createMockContext();
expect(getActionLabel("reject", context)).toBe("reject");
expect(getActionLabel("confirm", context)).toBe("confirm");
expect(getActionLabel("cancel", context)).toBe("cancel_event");
});
it("should return correct labels for recurring bookings", () => {
const context = createMockContext({ isRecurring: true, isTabRecurring: true });
expect(getActionLabel("reject", context)).toBe("reject_all");
expect(getActionLabel("confirm", context)).toBe("confirm_all");
expect(getActionLabel("cancel", context)).toBe("cancel_all_remaining");
});
it("should return correct no-show label based on attendee state", () => {
const contextWithNoShow = createMockContext({
attendeeList: [{ name: "John", email: "john@example.com", id: 1, noShow: true, phoneNumber: null }],
});
expect(getActionLabel("no_show", contextWithNoShow)).toBe("unmark_as_no_show");
});
});
});
@@ -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);
}
}
+8 -1
View File
@@ -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");
@@ -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/");
@@ -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/");
+4 -1
View File
@@ -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
@@ -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");
@@ -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");
+11 -5
View File
@@ -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();
@@ -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();
@@ -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 }) => {
@@ -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",