refactor: extract logic as bookingDetailsSheetStore (#25129)

* refactor: extract logic as bookingDetailsSheetStore

* clean up

* revert something

* provide store from context
This commit is contained in:
Eunjae Lee
2025-11-14 14:50:20 +00:00
committed by GitHub
parent 8043505f3a
commit 7a0b8a9eba
7 changed files with 217 additions and 109 deletions
@@ -29,54 +29,88 @@ import {
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
import type { BookingListingStatus } from "../../../components/booking/types";
import {
useBookingDetailsSheetStore,
useBookingDetailsSheetStoreApi,
} from "../store/bookingDetailsSheetStore";
import type { BookingOutput } from "../types";
import { JoinMeetingButton } from "./JoinMeetingButton";
type BookingMetaData = z.infer<typeof bookingMetadataSchema>;
interface BookingDetailsSheetProps {
booking: BookingOutput | null;
isOpen: boolean;
onClose: () => void;
userTimeZone?: string;
userTimeFormat?: number;
userId?: number;
userEmail?: string;
onPrevious?: () => void;
hasPrevious?: boolean;
onNext?: () => void;
hasNext?: boolean;
}
interface BookingDetailsSheetInnerProps extends Omit<BookingDetailsSheetProps, "booking"> {
booking: BookingOutput;
}
export function BookingDetailsSheet({
userTimeZone,
userTimeFormat,
userId,
userEmail,
}: BookingDetailsSheetProps) {
const booking = useBookingDetailsSheetStore((state) => state.getSelectedBooking());
export function BookingDetailsSheet(props: BookingDetailsSheetProps) {
if (!props.booking) return null;
// Return null if no booking is selected (sheet is closed)
if (!booking) return null;
return (
<BookingActionsStoreProvider>
<BookingDetailsSheetInner {...props} booking={props.booking} />
<BookingDetailsSheetInner
booking={booking}
userTimeZone={userTimeZone}
userTimeFormat={userTimeFormat}
userId={userId}
userEmail={userEmail}
/>
</BookingActionsStoreProvider>
);
}
interface BookingDetailsSheetInnerProps {
booking: BookingOutput;
userTimeZone?: string;
userTimeFormat?: number;
userId?: number;
userEmail?: string;
}
function BookingDetailsSheetInner({
booking,
isOpen,
onClose,
userTimeZone,
userTimeFormat,
userId,
userEmail,
onPrevious,
hasPrevious = false,
onNext,
hasNext = false,
}: BookingDetailsSheetInnerProps) {
const { t } = useLocale();
// Get navigation state directly from the store
const hasNext = useBookingDetailsSheetStore((state) => state.hasNext());
const hasPrevious = useBookingDetailsSheetStore((state) => state.hasPrevious());
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
const handleClose = () => {
setSelectedBookingId(null);
};
const storeApi = useBookingDetailsSheetStoreApi();
const handleNext = () => {
const nextId = storeApi.getState().getNextBookingId();
if (nextId !== null) {
setSelectedBookingId(nextId);
}
};
const handlePrevious = () => {
const prevId = storeApi.getState().getPreviousBookingId();
if (prevId !== null) {
setSelectedBookingId(prevId);
}
};
const startTime = dayjs(booking.startTime).tz(userTimeZone);
const endTime = dayjs(booking.endTime).tz(userTimeZone);
@@ -115,7 +149,7 @@ function BookingDetailsSheetInner({
: [];
return (
<Sheet open={isOpen} onOpenChange={onClose}>
<Sheet open={true} onOpenChange={handleClose}>
<SheetContent className="overflow-y-auto">
<SheetHeader
showCloseButton={false}
@@ -128,7 +162,7 @@ function BookingDetailsSheetInner({
disabled={!hasPrevious}
onClick={(e) => {
e.preventDefault();
onPrevious?.();
handlePrevious();
}}
/>
<Button
@@ -138,7 +172,7 @@ function BookingDetailsSheetInner({
disabled={!hasNext}
onClick={(e) => {
e.preventDefault();
onNext?.();
handleNext();
}}
/>
</div>
@@ -190,7 +224,7 @@ function BookingDetailsSheetInner({
<SheetFooter className="bg-muted border-subtle -mx-4 -mb-4 border-t pt-0 sm:-mx-6 sm:-my-6">
<div className="flex w-full flex-row items-center justify-between gap-2 px-4 pb-4 pt-4">
<Button color="secondary" StartIcon="x" onClick={onClose}>
<Button color="secondary" StartIcon="x" onClick={handleClose}>
{t("close")}
</Button>
@@ -19,7 +19,6 @@ type BookingsCalendarProps = {
status: BookingListingStatus;
table: ReactTable<RowData>;
isPending?: boolean;
onOpenDetails: (bookingId: number) => void;
currentWeekStart: dayjs.Dayjs;
setCurrentWeekStart: (
value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null
@@ -32,7 +31,6 @@ const COLUMN_IDS_TO_HIDE = ["dateRange"];
export function BookingsCalendar({
table,
isPending = false,
onOpenDetails,
currentWeekStart,
setCurrentWeekStart,
bookings,
@@ -77,7 +75,6 @@ export function BookingsCalendar({
currentWeekStart={currentWeekStart}
onWeekStartChange={handleWeekStartChange}
isPending={isPending}
onOpenDetails={onOpenDetails}
/>
</>
);
@@ -2,7 +2,7 @@
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
import { createParser, useQueryState } from "nuqs";
import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -11,8 +11,7 @@ import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
import { useBookingCursor } from "../hooks/useBookingCursor";
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
import { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore";
import type { RowData, BookingListingStatus } from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingsCalendar } from "./BookingsCalendar";
@@ -43,19 +42,11 @@ export function BookingsCalendarContainer({
const { t } = useLocale();
const user = useMeQuery().data;
const [selectedBookingId, setSelectedBookingId] = useSelectedBookingId();
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
"weekStart",
weekStartParser.withDefault(dayjs().startOf("week"))
);
const onOpenDetails = useCallback(
(bookingId: number) => {
setSelectedBookingId(bookingId);
},
[setSelectedBookingId]
);
const columns = useMemo(() => {
return buildFilterColumns({ t, permissions, status });
}, [t, permissions, status]);
@@ -90,42 +81,23 @@ export function BookingsCalendarContainer({
});
}, [data, currentWeekStart]);
const selectedBooking = useMemo(() => {
if (!selectedBookingId) return null;
return bookings.find((booking) => booking.id === selectedBookingId) ?? null;
}, [selectedBookingId, bookings]);
const bookingNavigation = useBookingCursor({
bookings,
selectedBookingId,
setSelectedBookingId,
});
return (
<>
<BookingDetailsSheetStoreProvider bookings={bookings}>
<BookingsCalendar
status={status}
table={table}
isPending={isPending}
onOpenDetails={onOpenDetails}
currentWeekStart={currentWeekStart}
setCurrentWeekStart={setCurrentWeekStart}
bookings={bookings}
/>
<BookingDetailsSheet
booking={selectedBooking}
isOpen={!!selectedBooking}
onClose={() => setSelectedBookingId(null)}
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
onPrevious={bookingNavigation.onPrevious}
hasPrevious={bookingNavigation.hasPrevious}
onNext={bookingNavigation.onNext}
hasNext={bookingNavigation.hasNext}
/>
</>
</BookingDetailsSheetStoreProvider>
);
}
@@ -12,6 +12,7 @@ import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { Icon } from "@calcom/ui/components/icon";
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
import type { BookingOutput } from "../types";
type BookingsCalendarViewProps = {
@@ -19,7 +20,6 @@ type BookingsCalendarViewProps = {
currentWeekStart: dayjs.Dayjs;
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
isPending?: boolean;
onOpenDetails: (bookingId: number) => void;
};
export function BookingsCalendarView({
@@ -27,8 +27,8 @@ export function BookingsCalendarView({
currentWeekStart,
onWeekStartChange,
isPending = false,
onOpenDetails,
}: BookingsCalendarViewProps) {
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
const { t } = useLocale();
const { timezone } = useTimePreferences();
const { resolvedTheme, forcedTheme } = useGetTheme();
@@ -152,7 +152,7 @@ export function BookingsCalendarView({
onEventClick={(event) => {
const bookingId = event.options?.bookingId;
if (bookingId) {
onOpenDetails(bookingId);
setSelectedBookingId(bookingId);
}
}}
hideHeader
@@ -10,6 +10,7 @@ import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import SkeletonLoader from "@components/booking/SkeletonLoader";
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
import type { RowData, BookingListingStatus } from "../types";
const descriptionByStatus: Record<BookingListingStatus, string> = {
@@ -25,25 +26,19 @@ type BookingsListViewProps = {
table: ReactTable<RowData>;
isPending: boolean;
totalRowCount?: number;
onOpenDetails: (bookingId: number) => void;
};
export function BookingsList({
status,
table,
isPending,
totalRowCount,
onOpenDetails,
}: BookingsListViewProps) {
export function BookingsList({ status, table, isPending, totalRowCount }: BookingsListViewProps) {
const { t } = useLocale();
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
const handleRowClick = useCallback(
(row: Row<RowData>) => {
if (!isSeparatorRow(row.original)) {
onOpenDetails(row.original.booking.id);
setSelectedBookingId(row.original.booking.id);
}
},
[onOpenDetails]
[setSelectedBookingId]
);
return (
@@ -16,8 +16,7 @@ import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues"
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
import { buildListDisplayColumns } from "../columns/listColumns";
import { useBookingCursor } from "../hooks/useBookingCursor";
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
import { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore";
import type { RowData, BookingListingStatus } from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingsList } from "./BookingsList";
@@ -43,8 +42,6 @@ export function BookingsListContainer({
const user = useMeQuery().data;
const utils = trpc.useUtils();
const [selectedBookingId, setSelectedBookingId] = useSelectedBookingId();
// Filter out separator rows and extract bookings
const bookings = useMemo(() => {
return data
@@ -52,17 +49,6 @@ export function BookingsListContainer({
.map((row) => row.booking);
}, [data]);
const selectedBooking = useMemo(() => {
if (!selectedBookingId) return null;
return bookings.find((booking) => booking.id === selectedBookingId) ?? null;
}, [selectedBookingId, bookings]);
const bookingNavigation = useBookingCursor({
bookings,
selectedBookingId,
setSelectedBookingId,
});
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const [rejectionReason, setRejectionReason] = useState<string>("");
const [pendingRejection, setPendingRejection] = useState<{
@@ -117,13 +103,6 @@ export function BookingsListContainer({
});
}, [pendingRejection, rejectionReason, confirmMutation]);
const onOpenDetails = useCallback(
(bookingId: number) => {
setSelectedBookingId(bookingId);
},
[setSelectedBookingId]
);
const columns = useMemo(() => {
const filterCols = buildFilterColumns({ t, permissions, status });
const listCols = buildListDisplayColumns({
@@ -163,7 +142,7 @@ export function BookingsListContainer({
}, []);
return (
<>
<BookingDetailsSheetStoreProvider bookings={bookings}>
<Dialog open={rejectionDialogIsOpen} onOpenChange={handleRejectionDialogChange}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
@@ -192,27 +171,14 @@ export function BookingsListContainer({
</DialogContent>
</Dialog>
<BookingsList
status={status}
table={table}
isPending={isPending}
totalRowCount={totalRowCount}
onOpenDetails={onOpenDetails}
/>
<BookingsList status={status} table={table} isPending={isPending} totalRowCount={totalRowCount} />
<BookingDetailsSheet
booking={selectedBooking}
isOpen={!!selectedBooking}
onClose={() => setSelectedBookingId(null)}
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
onPrevious={bookingNavigation.onPrevious}
hasPrevious={bookingNavigation.hasPrevious}
onNext={bookingNavigation.onNext}
hasNext={bookingNavigation.hasNext}
/>
</>
</BookingDetailsSheetStoreProvider>
);
}
@@ -0,0 +1,144 @@
"use client";
import React from "react";
import { createStore, useStore } from "zustand";
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
import type { BookingOutput } from "../types";
interface BookingDetailsSheetStore {
// State
selectedBookingId: number | null;
bookings: BookingOutput[];
// Actions
setSelectedBookingId: (id: number | null) => void;
setBookings: (bookings: BookingOutput[]) => void;
clearSelection: () => void;
// Computed getters (used via selectors)
getSelectedBooking: () => BookingOutput | null;
getNextBookingId: () => number | null;
getPreviousBookingId: () => number | null;
hasNext: () => boolean;
hasPrevious: () => boolean;
}
type BookingDetailsSheetStoreType = ReturnType<typeof createBookingDetailsSheetStore>;
const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) => {
return createStore<BookingDetailsSheetStore>((set, get) => ({
// Initial state
selectedBookingId: null,
bookings: initialBookings,
// Actions
setSelectedBookingId: (id) => set({ selectedBookingId: id }),
setBookings: (bookings) => set({ bookings }),
clearSelection: () => set({ selectedBookingId: null }),
// Computed getters
getSelectedBooking: () => {
const state = get();
if (!state.selectedBookingId) return null;
return state.bookings.find((booking) => booking.id === state.selectedBookingId) ?? null;
},
getNextBookingId: () => {
const state = get();
if (!state.selectedBookingId) return null;
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
if (currentIndex === -1 || currentIndex >= state.bookings.length - 1) return null;
return state.bookings[currentIndex + 1].id;
},
getPreviousBookingId: () => {
const state = get();
if (!state.selectedBookingId) return null;
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
if (currentIndex <= 0) return null;
return state.bookings[currentIndex - 1].id;
},
hasNext: () => {
const state = get();
if (!state.selectedBookingId) return false;
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
return currentIndex >= 0 && currentIndex < state.bookings.length - 1;
},
hasPrevious: () => {
const state = get();
if (!state.selectedBookingId) return false;
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
return currentIndex > 0;
},
}));
};
const BookingDetailsSheetStoreContext = React.createContext<BookingDetailsSheetStoreType | null>(null);
export function BookingDetailsSheetStoreProvider({
children,
bookings,
}: {
children: React.ReactNode;
bookings: BookingOutput[];
}) {
const [store] = React.useState(() => createBookingDetailsSheetStore(bookings));
const [selectedBookingIdFromUrl, setSelectedBookingIdToUrl] = useSelectedBookingId();
// Update bookings when they change
React.useEffect(() => {
store.getState().setBookings(bookings);
}, [bookings, store]);
// Sync Store → URL
React.useEffect(() => {
const unsubscribe = store.subscribe((state) => {
const storeId = state.selectedBookingId;
if (storeId !== selectedBookingIdFromUrl) {
setSelectedBookingIdToUrl(storeId);
}
});
return unsubscribe;
}, [selectedBookingIdFromUrl, setSelectedBookingIdToUrl, store]);
// Sync URL → Store
React.useEffect(() => {
const currentStoreId = store.getState().selectedBookingId;
if (currentStoreId !== selectedBookingIdFromUrl) {
store.getState().setSelectedBookingId(selectedBookingIdFromUrl);
}
}, [selectedBookingIdFromUrl, store]);
return (
<BookingDetailsSheetStoreContext.Provider value={store}>
{children}
</BookingDetailsSheetStoreContext.Provider>
);
}
export function useBookingDetailsSheetStore<T>(selector: (state: BookingDetailsSheetStore) => T): T {
const store = React.useContext(BookingDetailsSheetStoreContext);
if (!store) {
throw new Error("useBookingDetailsSheetStore must be used within BookingDetailsSheetStoreProvider");
}
return useStore(store, selector);
}
// For direct store access (needed for subscribe and getState)
export function useBookingDetailsSheetStoreApi() {
const store = React.useContext(BookingDetailsSheetStoreContext);
if (!store) {
throw new Error("useBookingDetailsSheetStoreApi must be used within BookingDetailsSheetStoreProvider");
}
return store;
}