Skip to content

Commit 16a7bb7

Browse files
refactor: Booking list items actions UI (calcom#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>
1 parent ee9699e commit 16a7bb7

8 files changed

Lines changed: 909 additions & 227 deletions

File tree

apps/web/components/booking/BookingListItem.tsx

Lines changed: 173 additions & 218 deletions
Large diffs are not rendered by default.

apps/web/components/booking/bookingActions.test.ts

Lines changed: 462 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
2+
import type { ActionType } from "@calcom/ui/components/table";
3+
4+
import type { BookingItemProps } from "./BookingListItem";
5+
6+
export interface BookingActionContext {
7+
booking: BookingItemProps;
8+
isUpcoming: boolean;
9+
isOngoing: boolean;
10+
isBookingInPast: boolean;
11+
isCancelled: boolean;
12+
isConfirmed: boolean;
13+
isRejected: boolean;
14+
isPending: boolean;
15+
isRescheduled: boolean;
16+
isRecurring: boolean;
17+
isTabRecurring: boolean;
18+
isTabUnconfirmed: boolean;
19+
isBookingFromRoutingForm: boolean;
20+
isDisabledCancelling: boolean;
21+
isDisabledRescheduling: boolean;
22+
isCalVideoLocation: boolean;
23+
showPendingPayment: boolean;
24+
cardCharged: boolean;
25+
attendeeList: Array<{
26+
name: string;
27+
email: string;
28+
id: number;
29+
noShow: boolean;
30+
phoneNumber: string | null;
31+
}>;
32+
getSeatReferenceUid: () => string | undefined;
33+
t: (key: string) => string;
34+
}
35+
36+
export function getPendingActions(context: BookingActionContext): ActionType[] {
37+
const { booking, isPending, isTabRecurring, isTabUnconfirmed, isRecurring, showPendingPayment, t } =
38+
context;
39+
40+
const actions: ActionType[] = [
41+
{
42+
id: "reject",
43+
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
44+
icon: "ban",
45+
disabled: false, // This would be controlled by mutation state in the component
46+
},
47+
];
48+
49+
// For bookings with payment, only confirm if the booking is paid for
50+
// Original logic: (isPending && !paymentAppData.enabled) || (paymentAppData.enabled && !!paymentAppData.price && booking.paid)
51+
if ((isPending && !showPendingPayment) || (showPendingPayment && booking.paid)) {
52+
actions.push({
53+
id: "confirm",
54+
bookingId: booking.id,
55+
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
56+
icon: "check" as const,
57+
disabled: false, // This would be controlled by mutation state in the component
58+
});
59+
}
60+
61+
return actions;
62+
}
63+
64+
export function getCancelEventAction(context: BookingActionContext): ActionType {
65+
const { booking, isTabRecurring, isRecurring, getSeatReferenceUid, t } = context;
66+
67+
return {
68+
id: "cancel",
69+
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event"),
70+
href: `/booking/${booking.uid}?cancel=true${
71+
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
72+
}${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""}`,
73+
icon: "circle-x",
74+
color: "destructive",
75+
disabled: isActionDisabled("cancel", context),
76+
};
77+
}
78+
79+
export function getVideoOptionsActions(context: BookingActionContext): ActionType[] {
80+
const { booking, isBookingInPast, isConfirmed, isCalVideoLocation, t } = context;
81+
82+
return [
83+
{
84+
id: "view_recordings",
85+
label: t("view_recordings"),
86+
icon: "video",
87+
disabled: !(isBookingInPast && isConfirmed && isCalVideoLocation && booking.isRecorded),
88+
},
89+
{
90+
id: "meeting_session_details",
91+
label: t("view_session_details"),
92+
icon: "info",
93+
disabled: !(isBookingInPast && isConfirmed && isCalVideoLocation),
94+
},
95+
];
96+
}
97+
98+
export function getEditEventActions(context: BookingActionContext): ActionType[] {
99+
const {
100+
booking,
101+
isBookingInPast,
102+
isDisabledRescheduling,
103+
isBookingFromRoutingForm,
104+
getSeatReferenceUid,
105+
t,
106+
} = context;
107+
108+
const actions: (ActionType | null)[] = [
109+
{
110+
id: "reschedule",
111+
icon: "clock",
112+
label: t("reschedule_booking"),
113+
href: `/reschedule/${booking.uid}${
114+
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
115+
}`,
116+
disabled:
117+
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
118+
},
119+
{
120+
id: "reschedule_request",
121+
icon: "send",
122+
iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ",
123+
label: t("send_reschedule_request"),
124+
disabled:
125+
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
126+
},
127+
isBookingFromRoutingForm
128+
? {
129+
id: "reroute",
130+
label: t("reroute"),
131+
icon: "waypoints",
132+
disabled: false,
133+
}
134+
: null,
135+
{
136+
id: "change_location",
137+
label: t("edit_location"),
138+
icon: "map-pin",
139+
disabled: false,
140+
},
141+
booking.eventType?.disableGuests
142+
? null
143+
: {
144+
id: "add_members",
145+
label: t("additional_guests"),
146+
icon: "user-plus",
147+
disabled: false,
148+
},
149+
// Reassign (if round robin)
150+
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN
151+
? {
152+
id: "reassign",
153+
label: t("reassign"),
154+
icon: "users",
155+
disabled: false,
156+
}
157+
: null,
158+
];
159+
160+
return actions.filter(Boolean) as ActionType[];
161+
}
162+
163+
export function getAfterEventActions(context: BookingActionContext): ActionType[] {
164+
const { booking, cardCharged, attendeeList, t } = context;
165+
166+
const actions: (ActionType | null)[] = [
167+
...getVideoOptionsActions(context),
168+
booking.status === BookingStatus.ACCEPTED && booking.paid && booking.payment[0]?.paymentOption === "HOLD"
169+
? {
170+
id: "charge_card",
171+
label: cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee"),
172+
icon: "credit-card",
173+
disabled: cardCharged,
174+
}
175+
: null,
176+
{
177+
id: "no_show",
178+
label:
179+
attendeeList.length === 1 && attendeeList[0].noShow ? t("unmark_as_no_show") : t("mark_as_no_show"),
180+
icon: attendeeList.length === 1 && attendeeList[0].noShow ? "eye" : "eye-off",
181+
disabled: false, // This would be controlled by booking state in the component
182+
},
183+
];
184+
185+
return actions.filter(Boolean) as ActionType[];
186+
}
187+
188+
export function shouldShowPendingActions(context: BookingActionContext): boolean {
189+
const { isPending, isUpcoming, isCancelled } = context;
190+
return isPending && isUpcoming && !isCancelled;
191+
}
192+
193+
export function shouldShowEditActions(context: BookingActionContext): boolean {
194+
const { isPending, isTabRecurring, isRecurring, isCancelled } = context;
195+
return !isPending && !(isTabRecurring && isRecurring) && !isCancelled;
196+
}
197+
198+
export function shouldShowRecurringCancelAction(context: BookingActionContext): boolean {
199+
const { isTabRecurring, isRecurring } = context;
200+
return isTabRecurring && isRecurring;
201+
}
202+
203+
export function isActionDisabled(actionId: string, context: BookingActionContext): boolean {
204+
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isPending, isConfirmed } =
205+
context;
206+
207+
switch (actionId) {
208+
case "reschedule":
209+
case "reschedule_request":
210+
return (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling;
211+
case "cancel":
212+
return isDisabledCancelling || (isBookingInPast && isPending && !isConfirmed);
213+
case "view_recordings":
214+
return !(isBookingInPast && booking.status === BookingStatus.ACCEPTED && context.isCalVideoLocation);
215+
case "meeting_session_details":
216+
return !(isBookingInPast && booking.status === BookingStatus.ACCEPTED && context.isCalVideoLocation);
217+
case "charge_card":
218+
return context.cardCharged;
219+
default:
220+
return false;
221+
}
222+
}
223+
224+
export function getActionLabel(actionId: string, context: BookingActionContext): string {
225+
const { booking, isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context;
226+
227+
switch (actionId) {
228+
case "reject":
229+
return (isTabRecurring || context.isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject");
230+
case "confirm":
231+
return (isTabRecurring || context.isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm");
232+
case "cancel":
233+
return isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event");
234+
case "no_show":
235+
return attendeeList.length === 1 && attendeeList[0].noShow
236+
? t("unmark_as_no_show")
237+
: t("mark_as_no_show");
238+
case "charge_card":
239+
return cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee");
240+
default:
241+
return t(actionId);
242+
}
243+
}

apps/web/playwright/booking-pages.e2e.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@ test.describe("pro user", () => {
155155
await pro.apiLogin();
156156
await page.goto("/bookings/upcoming");
157157
await page.waitForSelector('[data-testid="bookings"]');
158-
await page.locator('[data-testid="edit_booking"]').nth(0).click();
158+
// Click the ellipsis menu button to open the dropdown
159+
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
159160
await page.locator('[data-testid="reschedule"]').click();
160161
await page.waitForURL((url) => {
161162
const bookingId = url.searchParams.get("rescheduleUid");
@@ -205,6 +206,9 @@ test.describe("pro user", () => {
205206
await pro.apiLogin();
206207

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

239243
await page.goto("/bookings/upcoming");
244+
// Click the ellipsis menu button to open the dropdown
245+
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
246+
// Click the cancel option in the dropdown
240247
await page.locator('[data-testid="cancel"]').click();
241248
await page.waitForURL((url) => {
242249
return url.pathname.startsWith("/booking/");

apps/web/playwright/bookings-list.e2e.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,10 @@ test.describe("Bookings", () => {
363363
.locator(`[data-testid="select-filter-options-userId"] [role="option"]:has-text("${thirdUser.name}")`)
364364
.click();
365365
await bookingsGetResponse2;
366-
await expect(page.locator('text="Cancel event"').nth(0)).toBeVisible();
366+
// Click the ellipsis menu button to open the dropdown
367+
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
368+
// Check that the cancel option is visible in the dropdown
369+
await expect(page.locator('[data-testid="cancel"]')).toBeVisible();
367370

368371
//expect only 3 bookings (out of 4 total) to be shown in list.
369372
//where ThirdUser is either organizer or attendee

apps/web/playwright/dynamic-booking-pages.e2e.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ test("dynamic booking", async ({ page, users }) => {
3636
await test.step("can reschedule a booking", async () => {
3737
// Logged in
3838
await page.goto("/bookings/upcoming");
39-
await page.locator('[data-testid="edit_booking"]').nth(0).click();
39+
// Click the ellipsis menu button to open the dropdown
40+
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
41+
// Click the reschedule option in the dropdown
4042
await page.locator('[data-testid="reschedule"]').click();
4143
await page.waitForURL((url) => {
4244
const bookingId = url.searchParams.get("rescheduleUid");
@@ -54,6 +56,9 @@ test("dynamic booking", async ({ page, users }) => {
5456

5557
await test.step("Can cancel the recently created booking", async () => {
5658
await page.goto("/bookings/upcoming");
59+
// Click the ellipsis menu button to open the dropdown
60+
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
61+
// Click the cancel option in the dropdown
5762
await page.locator('[data-testid="cancel"]').click();
5863
await page.waitForURL((url) => {
5964
return url.pathname.startsWith("/booking");

apps/web/playwright/reschedule.e2e.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ test.describe("Reschedule Tests", async () => {
3333
await user.apiLogin();
3434
await page.goto("/bookings/upcoming");
3535

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

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

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

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

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

9294
await page.reload();
9395

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

96-
await expect(page.locator('[data-testid="reschedule"]')).toBeHidden();
97-
await expect(page.locator('[data-testid="reschedule_request"]')).toBeHidden();
99+
// Check that the reschedule options are visible but disabled
100+
await expect(page.locator('[data-testid="reschedule"]')).toBeVisible();
101+
await expect(page.locator('[data-testid="reschedule_request"]')).toBeVisible();
102+
await expect(page.locator('[data-testid="reschedule"]')).toBeDisabled();
103+
await expect(page.locator('[data-testid="reschedule_request"]')).toBeDisabled();
98104
});
99105

100106
test("Should display former time when rescheduling availability", async ({ page, users, bookings }) => {

apps/web/public/static/locales/en/common.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@
5050
"enable_automatic_transcription": "Enable automatic transcription after joining the meeting",
5151
"enable_automatic_recording": "Enable automatic recording after organizer joins the meeting",
5252
"video_options": "Video Options",
53-
"get_meeting_session_details": "Get Meeting Session Details",
53+
"edit_event": "Edit event",
54+
"view_session_details": "View Session Details",
5455
"meeting_session_details": "Meeting Session Details",
5556
"meeting_session": "Meeting Session",
5657
"session_id": "Session ID",

0 commit comments

Comments
 (0)