fix: prevent BookingDetailsSheet flicker when switching bookings (#27894)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
e2119879ae
commit
896dfd50fd
@@ -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<BookingDetailsSheetStore>((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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<BookingOutput | null>(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 (
|
||||
<BookingActionsStoreProvider>
|
||||
<BookingDetailsSheetInner
|
||||
booking={booking}
|
||||
booking={displayBooking}
|
||||
userTimeZone={userTimeZone}
|
||||
userTimeFormat={userTimeFormat}
|
||||
userId={userId}
|
||||
@@ -224,16 +235,13 @@ function BookingDetailsSheetInner({
|
||||
className="overflow-y-auto pb-0 sm:pb-0"
|
||||
hideOverlay
|
||||
onInteractOutside={(e) => {
|
||||
// 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)
|
||||
}}>
|
||||
<SheetHeader showCloseButton={false} className="mt-0 w-full">
|
||||
<div className="flex items-center justify-between gap-x-4">
|
||||
|
||||
@@ -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 (
|
||||
<Tooltip content={tooltipContent} className="max-w-none" side={tooltipSide}>
|
||||
<Component
|
||||
data-booking-calendar-event="true"
|
||||
onClick={() => onEventClick?.(event)} // Note this is not the button event. It is the calendar event.
|
||||
className={classNames(
|
||||
eventClasses({
|
||||
|
||||
Reference in New Issue
Block a user