From 896dfd50fd7d0c710210d7929fd2d77fdcafe0ab Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 13 Feb 2026 11:53:22 +0100 Subject: [PATCH] fix: prevent BookingDetailsSheet flicker when switching bookings (#27894) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/BookingDetailsSheet.test.tsx | 144 ++++++++++++++++++ .../components/BookingDetailsSheet.tsx | 28 ++-- .../weeklyview/components/event/Event.tsx | 7 +- 3 files changed, 165 insertions(+), 14 deletions(-) create mode 100644 apps/web/modules/bookings/components/BookingDetailsSheet.test.tsx diff --git a/apps/web/modules/bookings/components/BookingDetailsSheet.test.tsx b/apps/web/modules/bookings/components/BookingDetailsSheet.test.tsx new file mode 100644 index 0000000000..804f558e2b --- /dev/null +++ b/apps/web/modules/bookings/components/BookingDetailsSheet.test.tsx @@ -0,0 +1,144 @@ +import { act, renderHook } from "@testing-library/react"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createStore, useStore } from "zustand"; +import type { BookingOutput } from "../types"; + +type BookingDetailsSheetStore = { + selectedBookingUid: string | null; + bookings: BookingOutput[]; + setSelectedBookingUid: (uid: string | null) => void; + setBookings: (bookings: BookingOutput[]) => void; + getSelectedBooking: () => BookingOutput | null; +}; + +function createTestStore(initialBookings: BookingOutput[] = []) { + return createStore((set, get) => ({ + selectedBookingUid: null, + bookings: initialBookings, + setSelectedBookingUid: (uid) => set({ selectedBookingUid: uid }), + setBookings: (bookings) => set({ bookings }), + getSelectedBooking: () => { + const state = get(); + if (!state.selectedBookingUid) return null; + return state.bookings.find((booking) => booking.uid === state.selectedBookingUid) ?? null; + }, + })); +} + +const makeBooking = (uid: string, title: string): BookingOutput => + ({ + uid, + id: Number(uid), + title, + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + status: "ACCEPTED", + attendees: [], + metadata: null, + location: null, + rescheduled: false, + recurringEventId: null, + fromReschedule: null, + responses: {}, + payment: [], + eventType: null, + user: null, + assignmentReasonSortedByCreatedAt: [], + }) as unknown as BookingOutput; + +describe("BookingDetailsSheet transition behavior", () => { + const bookingA = makeBooking("uid-a", "Booking A"); + const bookingB = makeBooking("uid-b", "Booking B"); + + it("getSelectedBooking returns null when no uid is selected", () => { + const store = createTestStore([bookingA, bookingB]); + expect(store.getState().getSelectedBooking()).toBeNull(); + }); + + it("getSelectedBooking returns the correct booking when uid is set", () => { + const store = createTestStore([bookingA, bookingB]); + store.getState().setSelectedBookingUid("uid-a"); + expect(store.getState().getSelectedBooking()?.uid).toBe("uid-a"); + }); + + it("getSelectedBooking returns null when uid does not match any booking", () => { + const store = createTestStore([bookingA, bookingB]); + store.getState().setSelectedBookingUid("uid-nonexistent"); + expect(store.getState().getSelectedBooking()).toBeNull(); + }); + + it("switching between bookings updates getSelectedBooking immediately", () => { + const store = createTestStore([bookingA, bookingB]); + store.getState().setSelectedBookingUid("uid-a"); + expect(store.getState().getSelectedBooking()?.title).toBe("Booking A"); + + store.getState().setSelectedBookingUid("uid-b"); + expect(store.getState().getSelectedBooking()?.title).toBe("Booking B"); + }); + + describe("lastBookingRef fallback logic", () => { + it("preserves last booking when getSelectedBooking returns null during transition", () => { + let lastBooking: BookingOutput | null = null; + let selectedBookingUid: string | null = null; + + const store = createTestStore([bookingA, bookingB]); + store.getState().setSelectedBookingUid("uid-a"); + + const booking = store.getState().getSelectedBooking(); + if (booking) { + lastBooking = booking; + } + + store.getState().setSelectedBookingUid("uid-nonexistent"); + selectedBookingUid = store.getState().selectedBookingUid; + + const currentBooking = store.getState().getSelectedBooking(); + const displayBooking = currentBooking ?? lastBooking; + + expect(currentBooking).toBeNull(); + expect(selectedBookingUid).toBe("uid-nonexistent"); + expect(displayBooking?.uid).toBe("uid-a"); + }); + + it("clears lastBooking when selectedBookingUid is explicitly null", () => { + let lastBooking: BookingOutput | null = null; + + const store = createTestStore([bookingA]); + store.getState().setSelectedBookingUid("uid-a"); + + const booking = store.getState().getSelectedBooking(); + if (booking) { + lastBooking = booking; + } + + store.getState().setSelectedBookingUid(null); + const selectedBookingUid = store.getState().selectedBookingUid; + + if (!selectedBookingUid) { + lastBooking = null; + } + + const currentBooking = store.getState().getSelectedBooking(); + const displayBooking = currentBooking ?? lastBooking; + + expect(displayBooking).toBeNull(); + }); + + it("updates lastBooking when new booking is found", () => { + let lastBooking: BookingOutput | null = null; + + const store = createTestStore([bookingA, bookingB]); + + store.getState().setSelectedBookingUid("uid-a"); + const bookingAResult = store.getState().getSelectedBooking(); + if (bookingAResult) lastBooking = bookingAResult; + expect(lastBooking?.uid).toBe("uid-a"); + + store.getState().setSelectedBookingUid("uid-b"); + const bookingBResult = store.getState().getSelectedBooking(); + if (bookingBResult) lastBooking = bookingBResult; + expect(lastBooking?.uid).toBe("uid-b"); + }); + }); +}); diff --git a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx index 0532bdc9bc..c53658f727 100644 --- a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx +++ b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx @@ -34,7 +34,7 @@ import { ExternalLinkIcon, RepeatIcon } from "@coss/ui/icons"; import { BookingHistory } from "@calcom/web/modules/booking-audit/components/BookingHistory"; import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap"; import Link from "next/link"; -import { useMemo } from "react"; +import { useMemo, useRef } from "react"; import type { z } from "zod"; import { AcceptBookingButton } from "../../../components/booking/AcceptBookingButton"; import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown"; @@ -64,14 +64,25 @@ export function BookingDetailsSheet({ bookingAuditEnabled = false, }: BookingDetailsSheetProps) { const booking = useBookingDetailsSheetStore((state) => state.getSelectedBooking()); + const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid); + const lastBookingRef = useRef(null); - // Return null if no booking is selected (sheet is closed) - if (!booking) return null; + if (booking) { + lastBookingRef.current = booking; + } + + if (!selectedBookingUid) { + lastBookingRef.current = null; + } + + const displayBooking = booking ?? lastBookingRef.current; + + if (!displayBooking) return null; return ( { - // Check if the click is on a booking list item const target = e.target as HTMLElement; - const isBookingListItem = target.closest("[data-booking-list-item]"); + const isBookingItem = + target.closest("[data-booking-list-item]") || target.closest("[data-booking-calendar-event]"); - if (isBookingListItem) { - // Prevent closing when clicking a booking list item - // The item's onClick will handle opening the sheet with the new booking + if (isBookingItem) { e.preventDefault(); } - // If clicking elsewhere, allow the default behavior (close the sheet) }}>
diff --git a/apps/web/modules/calendars/weeklyview/components/event/Event.tsx b/apps/web/modules/calendars/weeklyview/components/event/Event.tsx index 1e5ad7c8ac..752fe6119e 100644 --- a/apps/web/modules/calendars/weeklyview/components/event/Event.tsx +++ b/apps/web/modules/calendars/weeklyview/components/event/Event.tsx @@ -1,11 +1,9 @@ -import { cva } from "class-variance-authority"; - import dayjs from "@calcom/dayjs"; +import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events"; import type { BookingStatus } from "@calcom/prisma/enums"; import classNames from "@calcom/ui/classNames"; import { Tooltip } from "@calcom/ui/components/tooltip"; - -import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events"; +import { cva } from "class-variance-authority"; type EventProps = { event: CalendarEvent; @@ -111,6 +109,7 @@ export function Event({ return ( onEventClick?.(event)} // Note this is not the button event. It is the calendar event. className={classNames( eventClasses({