mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
* fix: maintain uid parameter in BookingDetailsSheet URL Move the isSyncedFromUrlToStoreRef guard from the effect level to inside the Zustand subscription callback. This ensures the Store→URL subscription is always created while still preventing premature URL overwrites during URL→Store sync. Also adds an E2E test verifying the uid parameter appears in the URL after clicking a booking item in the v3 bookings view. Co-Authored-By: [email protected] <[email protected]> * Update apps/web/playwright/bookings-list.e2e.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix: clean up bookings-v3 feature flag after E2E test Co-Authored-By: [email protected] <[email protected]> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
268 lines
9.1 KiB
TypeScript
268 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import { createStore, useStore } from "zustand";
|
|
|
|
import { useActiveSegmentFromUrl } from "../hooks/useActiveSegmentFromUrl";
|
|
import { useSelectedBookingUid } from "../hooks/useSelectedBookingUid";
|
|
import type { BookingOutput } from "../types";
|
|
|
|
export type PendingSelectionType = "first" | "last" | null;
|
|
|
|
/**
|
|
* Capabilities interface for view-specific navigation logic.
|
|
* This allows different views (list/calendar) to provide their own
|
|
* navigation implementations without the core store knowing about them.
|
|
*/
|
|
export interface NavigationCapabilities {
|
|
/**
|
|
* Check if navigation to the previous period is possible
|
|
* (e.g., previous page in list view, previous week in calendar view)
|
|
*/
|
|
canNavigateToPreviousPeriod: () => boolean;
|
|
|
|
/**
|
|
* Check if navigation to the next period is possible
|
|
* (e.g., next page in list view, next week in calendar view)
|
|
*/
|
|
canNavigateToNextPeriod: () => boolean;
|
|
|
|
/**
|
|
* Request navigation to the previous period.
|
|
* This should trigger the view state update (e.g., page index change).
|
|
* The parent component will handle fetching data and updating bookings.
|
|
*/
|
|
requestPreviousPeriod: () => void;
|
|
|
|
/**
|
|
* Request navigation to the next period.
|
|
* This should trigger the view state update (e.g., page index change).
|
|
* The parent component will handle fetching data and updating bookings.
|
|
*/
|
|
requestNextPeriod: () => void;
|
|
}
|
|
|
|
interface BookingDetailsSheetStore {
|
|
// Core state (view-agnostic)
|
|
selectedBookingUid: string | null;
|
|
activeSegment: "info" | "history" | null;
|
|
bookings: BookingOutput[];
|
|
isTransitioning: boolean;
|
|
pendingSelection: PendingSelectionType;
|
|
|
|
// Injected capabilities (provided by adapters)
|
|
capabilities: NavigationCapabilities | null;
|
|
|
|
// Core actions
|
|
setSelectedBookingUid: (uid: string | null) => void;
|
|
setActiveSegment: (segment: "info" | "history" | null) => void;
|
|
setBookings: (bookings: BookingOutput[]) => void;
|
|
setCapabilities: (capabilities: NavigationCapabilities | null) => void;
|
|
clearSelection: () => void;
|
|
clearPendingSelection: () => void;
|
|
setIsTransitioning: (isTransitioning: boolean) => void;
|
|
|
|
// Navigation methods (delegates to capabilities)
|
|
navigateNext: () => Promise<void>;
|
|
navigatePrevious: () => Promise<void>;
|
|
|
|
// Simple getters (no view-specific logic)
|
|
getSelectedBooking: () => BookingOutput | null;
|
|
getCurrentIndex: () => number;
|
|
hasNextInArray: () => boolean;
|
|
hasPreviousInArray: () => boolean;
|
|
isFirstInArray: () => boolean;
|
|
isLastInArray: () => boolean;
|
|
}
|
|
|
|
type BookingDetailsSheetStoreType = ReturnType<typeof createBookingDetailsSheetStore>;
|
|
|
|
const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) => {
|
|
return createStore<BookingDetailsSheetStore>((set, get) => ({
|
|
// Initial state
|
|
selectedBookingUid: null,
|
|
activeSegment: null,
|
|
bookings: initialBookings,
|
|
isTransitioning: false,
|
|
pendingSelection: null,
|
|
capabilities: null,
|
|
|
|
// Actions
|
|
setSelectedBookingUid: (uid) => {
|
|
set({ selectedBookingUid: uid });
|
|
},
|
|
setActiveSegment: (segment) => {
|
|
set({ activeSegment: segment });
|
|
},
|
|
setBookings: (bookings) => set({ bookings }),
|
|
setCapabilities: (capabilities) => set({ capabilities }),
|
|
clearSelection: () => set({ selectedBookingUid: null }),
|
|
clearPendingSelection: () => set({ pendingSelection: null }),
|
|
setIsTransitioning: (isTransitioning) => set({ isTransitioning }),
|
|
|
|
// Core getters
|
|
getSelectedBooking: () => {
|
|
const state = get();
|
|
if (!state.selectedBookingUid) return null;
|
|
return state.bookings.find((booking) => booking.uid === state.selectedBookingUid) ?? null;
|
|
},
|
|
|
|
getCurrentIndex: () => {
|
|
const { bookings, selectedBookingUid } = get();
|
|
if (!selectedBookingUid) return -1;
|
|
return bookings.findIndex((b) => b.uid === selectedBookingUid);
|
|
},
|
|
|
|
hasNextInArray: () => {
|
|
const { bookings, getCurrentIndex } = get();
|
|
const index = getCurrentIndex();
|
|
return index >= 0 && index < bookings.length - 1;
|
|
},
|
|
|
|
hasPreviousInArray: () => {
|
|
const { getCurrentIndex } = get();
|
|
return getCurrentIndex() > 0;
|
|
},
|
|
|
|
isFirstInArray: () => get().getCurrentIndex() === 0,
|
|
|
|
isLastInArray: () => {
|
|
const { bookings, getCurrentIndex } = get();
|
|
return getCurrentIndex() === bookings.length - 1;
|
|
},
|
|
|
|
// Navigation methods
|
|
navigateNext: async () => {
|
|
const state = get();
|
|
|
|
// Try navigating within current array first
|
|
if (state.hasNextInArray()) {
|
|
const nextIndex = state.getCurrentIndex() + 1;
|
|
set({ selectedBookingUid: state.bookings[nextIndex].uid });
|
|
return;
|
|
}
|
|
|
|
// Need to navigate to next period
|
|
if (!state.capabilities?.canNavigateToNextPeriod()) return;
|
|
|
|
// Set pending selection to "first" and mark as transitioning
|
|
set({ isTransitioning: true, pendingSelection: "first" });
|
|
// Trigger page/week change synchronously - the parent component will handle the data fetch
|
|
state.capabilities.requestNextPeriod();
|
|
},
|
|
|
|
navigatePrevious: async () => {
|
|
const state = get();
|
|
|
|
// Try navigating within current array first
|
|
if (state.hasPreviousInArray()) {
|
|
const prevIndex = state.getCurrentIndex() - 1;
|
|
set({ selectedBookingUid: state.bookings[prevIndex].uid });
|
|
return;
|
|
}
|
|
|
|
// Need to navigate to previous period
|
|
if (!state.capabilities?.canNavigateToPreviousPeriod()) {
|
|
return;
|
|
}
|
|
|
|
// Set pending selection to "last" and mark as transitioning
|
|
set({ isTransitioning: true, pendingSelection: "last" });
|
|
// Trigger page/week change synchronously - the parent component will handle the data fetch
|
|
state.capabilities.requestPreviousPeriod();
|
|
},
|
|
}));
|
|
};
|
|
|
|
const BookingDetailsSheetStoreContext = React.createContext<BookingDetailsSheetStoreType | null>(null);
|
|
|
|
// TODO: To Avoid this useEffect based double sync, we should return a wrapper store over Zustand and Nuqs.
|
|
// Certain states that are stored in query params would be fully powered by Nuqs and other states would be fully powered by Zustand and the wrapper store provides a generic interface to work with both
|
|
function useBiDirectionalSyncBetweenStoreAndUrl({ store }: { store: BookingDetailsSheetStoreType }) {
|
|
const [selectedBookingUidFromUrl, setSelectedBookingUidToUrl] = useSelectedBookingUid();
|
|
const [activeSegmentFromUrl, setActiveSegmentToUrl] = useActiveSegmentFromUrl();
|
|
const isSyncedFromUrlToStoreRef = useRef(false);
|
|
|
|
// Sync Store → URL
|
|
useEffect(() => {
|
|
const unsubscribe = store.subscribe((state) => {
|
|
if (!isSyncedFromUrlToStoreRef.current) return;
|
|
if (state.selectedBookingUid !== selectedBookingUidFromUrl) {
|
|
setSelectedBookingUidToUrl(state.selectedBookingUid);
|
|
}
|
|
if (state.activeSegment !== activeSegmentFromUrl) {
|
|
setActiveSegmentToUrl(state.activeSegment);
|
|
}
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, [selectedBookingUidFromUrl, activeSegmentFromUrl, store]);
|
|
|
|
// Sync URL → Store
|
|
useEffect(() => {
|
|
const state = store.getState();
|
|
if (selectedBookingUidFromUrl !== state.selectedBookingUid) {
|
|
state.setSelectedBookingUid(selectedBookingUidFromUrl);
|
|
}
|
|
|
|
if (activeSegmentFromUrl !== state.activeSegment) {
|
|
state.setActiveSegment(activeSegmentFromUrl);
|
|
}
|
|
isSyncedFromUrlToStoreRef.current = true;
|
|
}, [selectedBookingUidFromUrl, activeSegmentFromUrl, store]);
|
|
}
|
|
|
|
export function BookingDetailsSheetStoreProvider({
|
|
children,
|
|
bookings,
|
|
capabilities,
|
|
}: {
|
|
children: React.ReactNode;
|
|
bookings: BookingOutput[];
|
|
capabilities?: NavigationCapabilities | null;
|
|
}) {
|
|
const [store] = useState(() => createBookingDetailsSheetStore(bookings));
|
|
const previousBookingsRef = useRef<BookingOutput[]>(bookings);
|
|
|
|
// Update bookings in store
|
|
useEffect(() => {
|
|
const previousBookings = previousBookingsRef.current;
|
|
const hasBookingsChanged = bookings !== previousBookings;
|
|
|
|
if (!hasBookingsChanged) return;
|
|
|
|
store.getState().setBookings(bookings);
|
|
previousBookingsRef.current = bookings;
|
|
}, [bookings, store]);
|
|
|
|
// Update capabilities when they change
|
|
useEffect(() => {
|
|
store.getState().setCapabilities(capabilities ?? null);
|
|
}, [capabilities, store]);
|
|
|
|
useBiDirectionalSyncBetweenStoreAndUrl({ 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;
|
|
}
|