Skip to content

Commit 4c01f17

Browse files
sean-brydonRyukemeisterdevin-ai-integration[bot]hbjORbj
authored
fix: multiple widgets for Booker atom (#22925)
* fix manage test error * fix manage test error * update useBookerContext * wrap store provider around booker * replace useStore with useBookerStoreContext * replace useBookerStore with useBookerStoreContext * add initializer function for booker store provider * update props * fixup: pass more props * export StoreInitializeType type * replace useBookerStore with useBookerStoreContext * fixup: dont wrap BookerStoreProvider around booker directly * wrap BookerStoreProvider around booker web wrapper and fix local storage import * fix: wrap test components with BookerStoreProvider to fix failing unit tests - Create reusable test utility in test-utils.tsx with comprehensive mock store - Update Booker.test.tsx to use context-based testing approach - Fix DatePicker tests in both bookings and calendars packages - Simulate auto-advance behavior for month navigation tests - All 14 previously failing tests now pass Co-Authored-By: [email protected] <[email protected]> * add changesets * fix: complete migration from useBookerStore to useBookerStoreContext - Updated all remaining files to use useBookerStoreContext instead of useBookerStore - Fixed BookingFields.test.tsx by wrapping test component with BookerStoreProvider - Ensures all Booker components work within the new context-based store pattern - Resolves failing unit tests in CI by completing the store migration Co-Authored-By: [email protected] <[email protected]> * fix: add missing useBookerStoreContext import in BookerWebWrapper.tsx - Fixes TypeScript errors in CI where useBookerStoreContext was used but not imported - Completes the migration from useBookerStore to useBookerStoreContext pattern - All tests pass locally after this fix Co-Authored-By: [email protected] <[email protected]> * Revert "fix: add missing useBookerStoreContext import in BookerWebWrapper.tsx" This reverts commit 12947ca. * Revert "fix: complete migration from useBookerStore to useBookerStoreContext" This reverts commit 87d837f. * fixup: useBookerStoreContext and useInitializeBookerStoreContext for booker web wrapper * fixup: failing E2E tests * fixup: email embed breaking * fixup: troubleshooter crashing * replace useBookerstore with useBookerStoreContext --------- Co-authored-by: Rajiv Sahal <[email protected]> Co-authored-by: Ryukemeister <[email protected]> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Benny Joo <[email protected]>
1 parent d9817fc commit 4c01f17

32 files changed

+857
-539
lines changed

.changeset/fresh-rivers-glow.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@calcom/atoms": patch
3+
---
4+
5+
Fixes an issues for the Booker atom wherein when multiple widgets were being placed on the same page, changes made in one widget would also get reflected in the others.
6+

packages/features/bookings/Booker/Booker.tsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import dayjs from "@calcom/dayjs";
1010
import PoweredBy from "@calcom/ee/components/PoweredBy";
1111
import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe";
1212
import TurnstileCaptcha from "@calcom/features/auth/Turnstile";
13+
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
1314
import useSkipConfirmStep from "@calcom/features/bookings/Booker/components/hooks/useSkipConfirmStep";
1415
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
1516
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
@@ -39,7 +40,6 @@ import { NotFound } from "./components/Unavailable";
3940
import { useIsQuickAvailabilityCheckFeatureEnabled } from "./components/hooks/useIsQuickAvailabilityCheckFeatureEnabled";
4041
import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config";
4142
import framerFeatures from "./framer-features";
42-
import { useBookerStore } from "./store";
4343
import type { BookerProps, WrappedBookerProps } from "./types";
4444
import { isBookingDryRun } from "./utils/isBookingDryRun";
4545
import { isTimeSlotAvailable } from "./utils/isTimeslotAvailable";
@@ -85,9 +85,14 @@ const BookerComponent = ({
8585
}: BookerProps & WrappedBookerProps) => {
8686
const searchParams = useCompatSearchParams();
8787
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
88-
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow);
88+
const [bookerState, setBookerState] = useBookerStoreContext(
89+
(state) => [state.state, state.setState],
90+
shallow
91+
);
92+
93+
const selectedDate = useBookerStoreContext((state) => state.selectedDate);
94+
const setSelectedDate = useBookerStoreContext((state) => state.setSelectedDate);
8995

90-
const selectedDate = useBookerStore((state) => state.selectedDate);
9196
const {
9297
shouldShowFormInDialog,
9398
hasDarkBackground,
@@ -100,12 +105,15 @@ const BookerComponent = ({
100105
bookerLayouts,
101106
} = bookerLayout;
102107

103-
const [seatedEventData, setSeatedEventData] = useBookerStore(
108+
const [seatedEventData, setSeatedEventData] = useBookerStoreContext(
104109
(state) => [state.seatedEventData, state.setSeatedEventData],
105110
shallow
106111
);
107112
const { selectedTimeslot, setSelectedTimeslot, allSelectedTimeslots } = slots;
108-
const [dayCount, setDayCount] = useBookerStore((state) => [state.dayCount, state.setDayCount], shallow);
113+
const [dayCount, setDayCount] = useBookerStoreContext(
114+
(state) => [state.dayCount, state.setDayCount],
115+
shallow
116+
);
109117

110118
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots).filter(
111119
(slot) => dayjs(selectedDate).diff(slot, "day") <= 0
@@ -184,6 +192,10 @@ const BookerComponent = ({
184192
(bookerState === "booking" || (bookerState === "selecting_time" && skipConfirmStep))
185193
);
186194

195+
const onAvailableTimeSlotSelect = (time: string) => {
196+
setSelectedTimeslot(time);
197+
};
198+
187199
updateEmbedBookerState({ bookerState, slotsQuery: schedule });
188200

189201
useEffect(() => {
@@ -220,6 +232,7 @@ const BookerComponent = ({
220232
return bookerState === "booking" ? (
221233
<BookEventForm
222234
key={key}
235+
timeslot={selectedTimeslot}
223236
shouldRenderCaptcha={shouldRenderCaptcha}
224237
onCancel={() => {
225238
setSelectedTimeslot(null);
@@ -312,9 +325,7 @@ const BookerComponent = ({
312325
return (
313326
<>
314327
{event.data && !isPlatform ? <BookingPageTagManager eventType={event.data} /> : <></>}
315-
316328
{(isBookingDryRunProp || isBookingDryRun(searchParams)) && <DryRunMessage isEmbed={isEmbed} />}
317-
318329
<div
319330
className={classNames(
320331
// In a popup embed, if someone clicks outside the main(having main class or main tag), it closes the embed
@@ -394,6 +405,7 @@ const BookerComponent = ({
394405
/>
395406
)}
396407
<EventMeta
408+
selectedTimeslot={selectedTimeslot}
397409
classNames={{
398410
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
399411
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
@@ -480,6 +492,7 @@ const BookerComponent = ({
480492
ref={timeslotsRef}
481493
{...fadeInLeft}>
482494
<AvailableTimeSlots
495+
onAvailableTimeSlotSelect={onAvailableTimeSlotSelect}
483496
customClassNames={customClassNames?.availableTimeSlotsCustomClassNames}
484497
extraDays={extraDays}
485498
limitHeight={layout === BookerLayouts.MONTH_VIEW}
@@ -552,7 +565,6 @@ const BookerComponent = ({
552565
</m.span>
553566
)}
554567
</div>
555-
556568
<>
557569
{verifyCode && formEmail ? (
558570
<VerifyCodeDialog
@@ -571,7 +583,6 @@ const BookerComponent = ({
571583
<></>
572584
)}
573585
</>
574-
575586
<BookFormAsModal
576587
onCancel={() => setSelectedTimeslot(null)}
577588
visible={bookerState === "booking" && shouldShowFormInDialog}>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"use client";
2+
3+
import { createContext, useContext, useRef, type ReactNode, useEffect } from "react";
4+
import { useStore } from "zustand";
5+
import type { StoreApi } from "zustand";
6+
7+
import { createBookerStore, type BookerStore, type StoreInitializeType } from "./store";
8+
9+
export const BookerStoreContext = createContext<StoreApi<BookerStore> | null>(null);
10+
11+
export interface BookerStoreProviderProps {
12+
children: ReactNode;
13+
}
14+
15+
export const BookerStoreProvider = ({ children }: BookerStoreProviderProps) => {
16+
const storeRef = useRef<StoreApi<BookerStore>>();
17+
if (!storeRef.current) {
18+
storeRef.current = createBookerStore();
19+
}
20+
21+
return <BookerStoreContext.Provider value={storeRef.current}>{children}</BookerStoreContext.Provider>;
22+
};
23+
24+
export const useBookerStoreContext = <T,>(
25+
selector: (store: BookerStore) => T,
26+
equalityFn?: (a: T, b: T) => boolean
27+
): T => {
28+
const bookerStoreContext = useContext(BookerStoreContext);
29+
30+
if (!bookerStoreContext) {
31+
throw new Error("useBookerStoreContext must be used within BookerStoreProvider");
32+
}
33+
34+
return useStore(bookerStoreContext, selector, equalityFn);
35+
};
36+
37+
export const useInitializeBookerStoreContext = ({
38+
username,
39+
eventSlug,
40+
month,
41+
eventId,
42+
rescheduleUid = null,
43+
rescheduledBy = null,
44+
bookingData = null,
45+
verifiedEmail = null,
46+
layout,
47+
isTeamEvent,
48+
durationConfig,
49+
org,
50+
isInstantMeeting,
51+
timezone = null,
52+
teamMemberEmail,
53+
crmOwnerRecordType,
54+
crmAppSlug,
55+
crmRecordId,
56+
isPlatform = false,
57+
allowUpdatingUrlParams = true,
58+
}: StoreInitializeType) => {
59+
const bookerStoreContext = useContext(BookerStoreContext);
60+
61+
if (!bookerStoreContext) {
62+
throw new Error("useInitializeBookerStoreContext must be used within BookerStoreProvider");
63+
}
64+
65+
const initializeStore = useStore(bookerStoreContext, (state) => state.initialize);
66+
67+
useEffect(() => {
68+
initializeStore({
69+
username,
70+
eventSlug,
71+
month,
72+
eventId,
73+
rescheduleUid,
74+
rescheduledBy,
75+
bookingData,
76+
layout,
77+
isTeamEvent,
78+
org,
79+
verifiedEmail,
80+
durationConfig,
81+
isInstantMeeting,
82+
timezone,
83+
teamMemberEmail,
84+
crmOwnerRecordType,
85+
crmAppSlug,
86+
crmRecordId,
87+
isPlatform,
88+
allowUpdatingUrlParams,
89+
});
90+
}, [
91+
initializeStore,
92+
org,
93+
username,
94+
eventSlug,
95+
month,
96+
eventId,
97+
rescheduleUid,
98+
rescheduledBy,
99+
bookingData,
100+
layout,
101+
isTeamEvent,
102+
verifiedEmail,
103+
durationConfig,
104+
isInstantMeeting,
105+
timezone,
106+
teamMemberEmail,
107+
crmOwnerRecordType,
108+
crmAppSlug,
109+
crmRecordId,
110+
isPlatform,
111+
allowUpdatingUrlParams,
112+
]);
113+
};

packages/features/bookings/Booker/__tests__/Booker.test.tsx

Lines changed: 19 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,17 @@ import "@calcom/features/bookings/Booker/components/__mocks__/Section";
1010
import { constantsScenarios } from "@calcom/lib/__mocks__/constants";
1111
import "@calcom/lib/__mocks__/logger";
1212

13-
import { render, screen } from "@testing-library/react";
13+
import React from "react";
1414
import { vi } from "vitest";
1515

1616
import "@calcom/dayjs/__mocks__";
1717
import "@calcom/features/auth/Turnstile";
1818

1919
import { Booker } from "../Booker";
20-
import { useBookerStore } from "../store";
21-
import type { BookerState } from "../types";
20+
import { render, screen } from "./test-utils";
2221

2322
vi.mock("framer-motion", async (importOriginal) => {
24-
const actual = await importOriginal();
23+
const actual = (await importOriginal()) as any;
2524
return {
2625
...actual,
2726
};
@@ -87,27 +86,6 @@ vi.mock("@calcom/atoms/hooks/useIsPlatform", () => ({
8786
useIsPlatform: () => false,
8887
}));
8988

90-
// Update mockStoreState to include all required state
91-
const mockStoreState = {
92-
state: "booking" as BookerState,
93-
setState: vi.fn(),
94-
selectedDate: "2024-01-01",
95-
seatedEventData: {},
96-
setSeatedEventData: vi.fn(),
97-
tentativeSelectedTimeslots: [],
98-
setTentativeSelectedTimeslots: vi.fn(),
99-
dayCount: 7,
100-
setDayCount: vi.fn(),
101-
setSelectedTimeslot: vi.fn(),
102-
selectedTimeslot: null,
103-
formStep: 0,
104-
setFormStep: vi.fn(),
105-
bookerState: "booking",
106-
setBookerState: vi.fn(),
107-
layout: "default",
108-
setLayout: vi.fn(),
109-
};
110-
11189
// Update defaultProps to include missing required props
11290
const defaultProps = {
11391
username: "testuser",
@@ -188,32 +166,21 @@ const defaultProps = {
188166
describe("Booker", () => {
189167
beforeEach(() => {
190168
constantsScenarios.set({
191-
PUBLIC_QUICK_AVAILABILITY_ROLLOUT: 100,
169+
PUBLIC_QUICK_AVAILABILITY_ROLLOUT: "100",
192170
POWERED_BY_URL: "https://go.cal.com/booking",
193171
APP_NAME: "Cal.com",
194172
});
195173
vi.clearAllMocks();
196174
});
197175

198176
it("should render null when in loading state", () => {
199-
useBookerStore.setState({
200-
...mockStoreState,
201-
state: "loading",
177+
const { container } = render(<Booker {...defaultProps} />, {
178+
mockStore: { state: "loading" },
202179
});
203-
204-
const { container } = render(<Booker {...defaultProps} />);
205180
expect(container).toBeEmptyDOMElement();
206181
});
207182

208183
it("should render DryRunMessage when in dry run mode", () => {
209-
useBookerStore.setState({
210-
...mockStoreState,
211-
state: "selecting_time",
212-
selectedDate: "2024-01-01",
213-
selectedTimeslot: "2024-01-01T10:00:00Z",
214-
tentativeSelectedTimeslots: ["2024-01-01T10:00:00Z"],
215-
});
216-
217184
const propsWithDryRun = {
218185
...defaultProps,
219186
isBookingDryRun: true,
@@ -226,7 +193,14 @@ describe("Booker", () => {
226193
},
227194
};
228195

229-
render(<Booker {...propsWithDryRun} />);
196+
render(<Booker {...propsWithDryRun} />, {
197+
mockStore: {
198+
state: "selecting_time",
199+
selectedDate: "2024-01-01",
200+
selectedTimeslot: "2024-01-01T10:00:00Z",
201+
tentativeSelectedTimeslots: ["2024-01-01T10:00:00Z"],
202+
},
203+
});
230204
expect(screen.getByTestId("dry-run-message")).toBeInTheDocument();
231205
});
232206

@@ -242,12 +216,10 @@ describe("Booker", () => {
242216
invalidate: mockInvalidate,
243217
},
244218
};
245-
useBookerStore.setState({
246-
...mockStoreState,
247-
state: "booking",
248-
});
249219

250-
render(<Booker {...propsWithInvalidate} />);
220+
render(<Booker {...propsWithInvalidate} />, {
221+
mockStore: { state: "booking" },
222+
});
251223
screen.logTestingPlaygroundURL();
252224
// Trigger form cancel
253225
const cancelButton = screen.getByRole("button", { name: /cancel/i });
@@ -267,12 +239,9 @@ describe("Booker", () => {
267239
},
268240
};
269241

270-
useBookerStore.setState({
271-
...mockStoreState,
272-
state: "booking",
242+
render(<Booker {...propsWithQuickChecks} />, {
243+
mockStore: { state: "booking" },
273244
});
274-
275-
render(<Booker {...propsWithQuickChecks} />);
276245
const bookEventForm = screen.getByTestId("book-event-form");
277246
await expect(bookEventForm).toHaveAttribute("data-unavailable", "true");
278247
});

0 commit comments

Comments
 (0)