Skip to content

Commit 896dfd5

Browse files
fix: prevent BookingDetailsSheet flicker when switching bookings (calcom#27894)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent e211987 commit 896dfd5

3 files changed

Lines changed: 165 additions & 14 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import React from "react";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import { createStore, useStore } from "zustand";
5+
import type { BookingOutput } from "../types";
6+
7+
type BookingDetailsSheetStore = {
8+
selectedBookingUid: string | null;
9+
bookings: BookingOutput[];
10+
setSelectedBookingUid: (uid: string | null) => void;
11+
setBookings: (bookings: BookingOutput[]) => void;
12+
getSelectedBooking: () => BookingOutput | null;
13+
};
14+
15+
function createTestStore(initialBookings: BookingOutput[] = []) {
16+
return createStore<BookingDetailsSheetStore>((set, get) => ({
17+
selectedBookingUid: null,
18+
bookings: initialBookings,
19+
setSelectedBookingUid: (uid) => set({ selectedBookingUid: uid }),
20+
setBookings: (bookings) => set({ bookings }),
21+
getSelectedBooking: () => {
22+
const state = get();
23+
if (!state.selectedBookingUid) return null;
24+
return state.bookings.find((booking) => booking.uid === state.selectedBookingUid) ?? null;
25+
},
26+
}));
27+
}
28+
29+
const makeBooking = (uid: string, title: string): BookingOutput =>
30+
({
31+
uid,
32+
id: Number(uid),
33+
title,
34+
startTime: new Date().toISOString(),
35+
endTime: new Date().toISOString(),
36+
status: "ACCEPTED",
37+
attendees: [],
38+
metadata: null,
39+
location: null,
40+
rescheduled: false,
41+
recurringEventId: null,
42+
fromReschedule: null,
43+
responses: {},
44+
payment: [],
45+
eventType: null,
46+
user: null,
47+
assignmentReasonSortedByCreatedAt: [],
48+
}) as unknown as BookingOutput;
49+
50+
describe("BookingDetailsSheet transition behavior", () => {
51+
const bookingA = makeBooking("uid-a", "Booking A");
52+
const bookingB = makeBooking("uid-b", "Booking B");
53+
54+
it("getSelectedBooking returns null when no uid is selected", () => {
55+
const store = createTestStore([bookingA, bookingB]);
56+
expect(store.getState().getSelectedBooking()).toBeNull();
57+
});
58+
59+
it("getSelectedBooking returns the correct booking when uid is set", () => {
60+
const store = createTestStore([bookingA, bookingB]);
61+
store.getState().setSelectedBookingUid("uid-a");
62+
expect(store.getState().getSelectedBooking()?.uid).toBe("uid-a");
63+
});
64+
65+
it("getSelectedBooking returns null when uid does not match any booking", () => {
66+
const store = createTestStore([bookingA, bookingB]);
67+
store.getState().setSelectedBookingUid("uid-nonexistent");
68+
expect(store.getState().getSelectedBooking()).toBeNull();
69+
});
70+
71+
it("switching between bookings updates getSelectedBooking immediately", () => {
72+
const store = createTestStore([bookingA, bookingB]);
73+
store.getState().setSelectedBookingUid("uid-a");
74+
expect(store.getState().getSelectedBooking()?.title).toBe("Booking A");
75+
76+
store.getState().setSelectedBookingUid("uid-b");
77+
expect(store.getState().getSelectedBooking()?.title).toBe("Booking B");
78+
});
79+
80+
describe("lastBookingRef fallback logic", () => {
81+
it("preserves last booking when getSelectedBooking returns null during transition", () => {
82+
let lastBooking: BookingOutput | null = null;
83+
let selectedBookingUid: string | null = null;
84+
85+
const store = createTestStore([bookingA, bookingB]);
86+
store.getState().setSelectedBookingUid("uid-a");
87+
88+
const booking = store.getState().getSelectedBooking();
89+
if (booking) {
90+
lastBooking = booking;
91+
}
92+
93+
store.getState().setSelectedBookingUid("uid-nonexistent");
94+
selectedBookingUid = store.getState().selectedBookingUid;
95+
96+
const currentBooking = store.getState().getSelectedBooking();
97+
const displayBooking = currentBooking ?? lastBooking;
98+
99+
expect(currentBooking).toBeNull();
100+
expect(selectedBookingUid).toBe("uid-nonexistent");
101+
expect(displayBooking?.uid).toBe("uid-a");
102+
});
103+
104+
it("clears lastBooking when selectedBookingUid is explicitly null", () => {
105+
let lastBooking: BookingOutput | null = null;
106+
107+
const store = createTestStore([bookingA]);
108+
store.getState().setSelectedBookingUid("uid-a");
109+
110+
const booking = store.getState().getSelectedBooking();
111+
if (booking) {
112+
lastBooking = booking;
113+
}
114+
115+
store.getState().setSelectedBookingUid(null);
116+
const selectedBookingUid = store.getState().selectedBookingUid;
117+
118+
if (!selectedBookingUid) {
119+
lastBooking = null;
120+
}
121+
122+
const currentBooking = store.getState().getSelectedBooking();
123+
const displayBooking = currentBooking ?? lastBooking;
124+
125+
expect(displayBooking).toBeNull();
126+
});
127+
128+
it("updates lastBooking when new booking is found", () => {
129+
let lastBooking: BookingOutput | null = null;
130+
131+
const store = createTestStore([bookingA, bookingB]);
132+
133+
store.getState().setSelectedBookingUid("uid-a");
134+
const bookingAResult = store.getState().getSelectedBooking();
135+
if (bookingAResult) lastBooking = bookingAResult;
136+
expect(lastBooking?.uid).toBe("uid-a");
137+
138+
store.getState().setSelectedBookingUid("uid-b");
139+
const bookingBResult = store.getState().getSelectedBooking();
140+
if (bookingBResult) lastBooking = bookingBResult;
141+
expect(lastBooking?.uid).toBe("uid-b");
142+
});
143+
});
144+
});

apps/web/modules/bookings/components/BookingDetailsSheet.tsx

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { ExternalLinkIcon, RepeatIcon } from "@coss/ui/icons";
3434
import { BookingHistory } from "@calcom/web/modules/booking-audit/components/BookingHistory";
3535
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
3636
import Link from "next/link";
37-
import { useMemo } from "react";
37+
import { useMemo, useRef } from "react";
3838
import type { z } from "zod";
3939
import { AcceptBookingButton } from "../../../components/booking/AcceptBookingButton";
4040
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
@@ -64,14 +64,25 @@ export function BookingDetailsSheet({
6464
bookingAuditEnabled = false,
6565
}: BookingDetailsSheetProps) {
6666
const booking = useBookingDetailsSheetStore((state) => state.getSelectedBooking());
67+
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
68+
const lastBookingRef = useRef<BookingOutput | null>(null);
6769

68-
// Return null if no booking is selected (sheet is closed)
69-
if (!booking) return null;
70+
if (booking) {
71+
lastBookingRef.current = booking;
72+
}
73+
74+
if (!selectedBookingUid) {
75+
lastBookingRef.current = null;
76+
}
77+
78+
const displayBooking = booking ?? lastBookingRef.current;
79+
80+
if (!displayBooking) return null;
7081

7182
return (
7283
<BookingActionsStoreProvider>
7384
<BookingDetailsSheetInner
74-
booking={booking}
85+
booking={displayBooking}
7586
userTimeZone={userTimeZone}
7687
userTimeFormat={userTimeFormat}
7788
userId={userId}
@@ -224,16 +235,13 @@ function BookingDetailsSheetInner({
224235
className="overflow-y-auto pb-0 sm:pb-0"
225236
hideOverlay
226237
onInteractOutside={(e) => {
227-
// Check if the click is on a booking list item
228238
const target = e.target as HTMLElement;
229-
const isBookingListItem = target.closest("[data-booking-list-item]");
239+
const isBookingItem =
240+
target.closest("[data-booking-list-item]") || target.closest("[data-booking-calendar-event]");
230241

231-
if (isBookingListItem) {
232-
// Prevent closing when clicking a booking list item
233-
// The item's onClick will handle opening the sheet with the new booking
242+
if (isBookingItem) {
234243
e.preventDefault();
235244
}
236-
// If clicking elsewhere, allow the default behavior (close the sheet)
237245
}}>
238246
<SheetHeader showCloseButton={false} className="mt-0 w-full">
239247
<div className="flex items-center justify-between gap-x-4">

apps/web/modules/calendars/weeklyview/components/event/Event.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import { cva } from "class-variance-authority";
2-
31
import dayjs from "@calcom/dayjs";
2+
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
43
import type { BookingStatus } from "@calcom/prisma/enums";
54
import classNames from "@calcom/ui/classNames";
65
import { Tooltip } from "@calcom/ui/components/tooltip";
7-
8-
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
6+
import { cva } from "class-variance-authority";
97

108
type EventProps = {
119
event: CalendarEvent;
@@ -111,6 +109,7 @@ export function Event({
111109
return (
112110
<Tooltip content={tooltipContent} className="max-w-none" side={tooltipSide}>
113111
<Component
112+
data-booking-calendar-event="true"
114113
onClick={() => onEventClick?.(event)} // Note this is not the button event. It is the calendar event.
115114
className={classNames(
116115
eventClasses({

0 commit comments

Comments
 (0)