diff --git a/packages/web/src/common/utils/event/event.util.ts b/packages/web/src/common/utils/event/event.util.ts index 50ba8d6d2..e2a234a43 100644 --- a/packages/web/src/common/utils/event/event.util.ts +++ b/packages/web/src/common/utils/event/event.util.ts @@ -253,6 +253,24 @@ export const isOptimisticEvent = (event: Schema_Event) => { return isOptimistic; }; +export const getEventCursorStyle = ( + isDragging: boolean, + isOptimistic: boolean, +): string => { + if (isDragging) return "move"; + if (isOptimistic) return "wait"; + return "pointer"; +}; + +export const getEventCursorClass = ( + isDragging: boolean, + isOptimistic: boolean, +): string => { + if (isDragging) return "cursor-move"; + if (isOptimistic) return "cursor-wait"; + return "cursor-pointer"; +}; + export const prepEvtAfterDraftDrop = ( category: Categories_Event, dropItem: DropResult & Schema_Event, diff --git a/packages/web/src/ducks/events/sagas/event.sagas.test.ts b/packages/web/src/ducks/events/sagas/event.sagas.test.ts new file mode 100644 index 000000000..b6d0c46f2 --- /dev/null +++ b/packages/web/src/ducks/events/sagas/event.sagas.test.ts @@ -0,0 +1,263 @@ +import { AxiosResponse } from "axios"; +import { ID_OPTIMISTIC_PREFIX } from "@core/constants/core.constants"; +import { Schema_Event } from "@core/types/event.types"; +import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory"; +import { createStoreWithEvents } from "@web/__tests__/utils/state/store.test.util"; +import { sagaMiddleware } from "@web/common/store/middlewares"; +import { Schema_GridEvent } from "@web/common/types/web.event.types"; +import { isOptimisticEvent } from "@web/common/utils/event/event.util"; +import { EventApi } from "@web/ducks/events/event.api"; +import { selectEventById } from "@web/ducks/events/selectors/event.selectors"; +import { createEventSlice } from "@web/ducks/events/slices/event.slice"; +import { RootState } from "@web/store"; +import { sagas } from "@web/store/sagas"; +import { OnSubmitParser } from "@web/views/Calendar/components/Draft/hooks/actions/submit.parser"; + +jest.mock("@web/ducks/events/event.api"); + +describe("createEvent saga - optimistic rendering", () => { + let store: ReturnType; + let mockCreateApi: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + store = createStoreWithEvents([]); + sagaMiddleware.run(sagas); + + mockCreateApi = jest.spyOn(EventApi, "create").mockImplementation(() => { + return Promise.resolve({ + status: 200, + } as unknown as AxiosResponse); + }); + }); + + it("should immediately add event with optimistic ID when created", () => { + const gridEvent = createMockStandaloneEvent() as Schema_GridEvent; + const event = new OnSubmitParser(gridEvent).parse() as Schema_Event; + const action = createEventSlice.actions.request(event); + + store.dispatch(action); + + // Get all events from state + const state = store.getState(); + const eventEntities = state.events.entities.value || {}; + const eventIds = Object.keys(eventEntities); + + // Should have exactly one event + expect(eventIds).toHaveLength(1); + + const optimisticId = eventIds[0]; + const optimisticEvent = eventEntities[optimisticId]; + + // Event should have optimistic ID prefix + expect(optimisticId).toMatch(new RegExp(`^${ID_OPTIMISTIC_PREFIX}-`)); + expect(optimisticEvent._id).toBe(optimisticId); + expect(isOptimisticEvent(optimisticEvent)).toBe(true); + + // Event should be in week and day event lists + const weekEventIds = state.events.getWeekEvents.value?.data || []; + const dayEventIds = state.events.getDayEvents.value?.data || []; + + expect(weekEventIds).toContain(optimisticId); + expect(dayEventIds).toContain(optimisticId); + }); + + it("should keep event in state during API call", async () => { + // Create a promise that we can control + let resolveApiCall: (value: AxiosResponse) => void; + const apiPromise = new Promise>((resolve) => { + resolveApiCall = resolve; + }); + + mockCreateApi.mockImplementation(() => apiPromise); + + const gridEvent = createMockStandaloneEvent() as Schema_GridEvent; + const event = new OnSubmitParser(gridEvent).parse() as Schema_Event; + const action = createEventSlice.actions.request(event); + + store.dispatch(action); + + // Get the optimistic ID immediately after dispatch + const stateAfterDispatch = store.getState(); + const eventEntitiesAfterDispatch = + stateAfterDispatch.events.entities.value || {}; + const optimisticIds = Object.keys(eventEntitiesAfterDispatch); + expect(optimisticIds).toHaveLength(1); + const optimisticId = optimisticIds[0]; + + // Verify event is still in state while API call is pending + const stateDuringApiCall = store.getState(); + const eventDuringApiCall = selectEventById( + stateDuringApiCall as RootState, + optimisticId, + ); + + expect(eventDuringApiCall).not.toBeNull(); + expect(eventDuringApiCall?._id).toBe(optimisticId); + expect(isOptimisticEvent(eventDuringApiCall!)).toBe(true); + + // Verify event is still in week and day lists + const weekEventIdsDuringCall = + stateDuringApiCall.events.getWeekEvents.value?.data || []; + const dayEventIdsDuringCall = + stateDuringApiCall.events.getDayEvents.value?.data || []; + + expect(weekEventIdsDuringCall).toContain(optimisticId); + expect(dayEventIdsDuringCall).toContain(optimisticId); + + // Resolve the API call + resolveApiCall!({ + status: 200, + } as AxiosResponse); + + // Wait for saga to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify event is still in state after API call completes + // The optimistic ID should be replaced with real ID + const realEventId = optimisticId.replace(`${ID_OPTIMISTIC_PREFIX}-`, ""); + const stateAfterApiCall = store.getState(); + const eventAfterApiCall = selectEventById( + stateAfterApiCall as RootState, + realEventId, + ); + + // Event should still exist with real ID + expect(eventAfterApiCall).not.toBeNull(); + expect(eventAfterApiCall?._id).toBe(realEventId); + }); + + it("should replace optimistic ID with real ID after successful API call", async () => { + const gridEvent = createMockStandaloneEvent() as Schema_GridEvent; + const event = new OnSubmitParser(gridEvent).parse() as Schema_Event; + + // Mock API to return success + mockCreateApi.mockResolvedValue({ + status: 200, + } as AxiosResponse); + + const action = createEventSlice.actions.request(event); + store.dispatch(action); + + // Get optimistic ID immediately + const initialState = store.getState(); + const initialEventEntities = initialState.events.entities.value || {}; + const optimisticIds = Object.keys(initialEventEntities); + expect(optimisticIds).toHaveLength(1); + const optimisticId = optimisticIds[0]; + + // The real ID is the optimistic ID without the prefix + const realEventId = optimisticId.replace(`${ID_OPTIMISTIC_PREFIX}-`, ""); + + // Wait for saga to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + const finalState = store.getState(); + const eventEntities = finalState.events.entities.value || {}; + const eventIds = Object.keys(eventEntities); + + // Should still have exactly one event + expect(eventIds).toHaveLength(1); + + // The event should have the real ID (without optimistic prefix) + expect(eventIds[0]).toBe(realEventId); + expect(eventIds[0]).not.toMatch(new RegExp(`^${ID_OPTIMISTIC_PREFIX}-`)); + + // Verify the event is accessible by real ID + const finalEvent = selectEventById(finalState as RootState, realEventId); + expect(finalEvent).not.toBeNull(); + expect(finalEvent?._id).toBe(realEventId); + expect(isOptimisticEvent(finalEvent!)).toBe(false); + }); + + it("should never remove event from state after being added", async () => { + const gridEvent = createMockStandaloneEvent() as Schema_GridEvent; + const event = new OnSubmitParser(gridEvent).parse() as Schema_Event; + const action = createEventSlice.actions.request(event); + + store.dispatch(action); + + // Get optimistic ID immediately + const initialState = store.getState(); + const initialEventEntities = initialState.events.entities.value || {}; + const optimisticIds = Object.keys(initialEventEntities); + expect(optimisticIds).toHaveLength(1); + const optimisticId = optimisticIds[0]; + const realEventId = optimisticId.replace(`${ID_OPTIMISTIC_PREFIX}-`, ""); + + // Check 1: Immediately after dispatch (should have optimistic ID) + const check1 = store.getState(); + const event1 = selectEventById(check1 as RootState, optimisticId); + expect(event1).not.toBeNull(); + expect(event1?._id).toBe(optimisticId); + + // Wait for API call to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Check 2: After API call completes (should have real ID, not optimistic) + const check2 = store.getState(); + const event2Optimistic = selectEventById(check2 as RootState, optimisticId); + const event2Real = selectEventById(check2 as RootState, realEventId); + + // Event should no longer be accessible by optimistic ID + expect(event2Optimistic).toBeNull(); + // But should be accessible by real ID + expect(event2Real).not.toBeNull(); + expect(event2Real?._id).toBe(realEventId); + + // Final verification: event should exist with real ID + const finalState = store.getState(); + const finalEventEntities = finalState.events.entities.value || {}; + const finalEventCount = Object.keys(finalEventEntities).length; + + // Should have exactly one event + expect(finalEventCount).toBe(1); + expect(finalEventEntities[realEventId]).toBeDefined(); + + // Verify event count never dropped to zero + // The event should transition from optimistic ID to real ID without disappearing + expect(finalEventCount).toBeGreaterThanOrEqual(1); + }); + + it("should maintain event in week and day event lists throughout creation process", async () => { + const gridEvent = createMockStandaloneEvent() as Schema_GridEvent; + const event = new OnSubmitParser(gridEvent).parse() as Schema_Event; + const action = createEventSlice.actions.request(event); + + store.dispatch(action); + + // Get optimistic ID + const initialState = store.getState(); + const initialEventEntities = initialState.events.entities.value || {}; + const optimisticIds = Object.keys(initialEventEntities); + expect(optimisticIds).toHaveLength(1); + const optimisticId = optimisticIds[0]; + const realEventId = optimisticId.replace(`${ID_OPTIMISTIC_PREFIX}-`, ""); + + // Verify in lists immediately with optimistic ID + const initialWeekIds = initialState.events.getWeekEvents.value?.data || []; + const initialDayIds = initialState.events.getDayEvents.value?.data || []; + expect(initialWeekIds).toContain(optimisticId); + expect(initialDayIds).toContain(optimisticId); + + // Wait for API call to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify still in lists but with real ID (replaced) + const finalState = store.getState(); + const finalWeekIds = finalState.events.getWeekEvents.value?.data || []; + const finalDayIds = finalState.events.getDayEvents.value?.data || []; + + // Should no longer have optimistic ID + expect(finalWeekIds).not.toContain(optimisticId); + expect(finalDayIds).not.toContain(optimisticId); + + // Should have real ID in both lists + expect(finalWeekIds).toContain(realEventId); + expect(finalDayIds).toContain(realEventId); + + // Should have exactly one event in each list + expect(finalWeekIds).toHaveLength(1); + expect(finalDayIds).toHaveLength(1); + }); +}); diff --git a/packages/web/src/ducks/events/sagas/event.sagas.ts b/packages/web/src/ducks/events/sagas/event.sagas.ts index 0e8011c83..77884b1e6 100644 --- a/packages/web/src/ducks/events/sagas/event.sagas.ts +++ b/packages/web/src/ducks/events/sagas/event.sagas.ts @@ -72,6 +72,7 @@ export function* convertCalendarToSomedayEvent({ } yield put(getWeekEventsSlice.actions.insert(payload.event._id!)); + yield put(getDayEventsSlice.actions.insert(payload.event._id!)); yield put(editEventSlice.actions.error()); handleError(error as Error); @@ -103,6 +104,7 @@ export function* deleteEvent({ payload }: Action_DeleteEvent) { try { yield put(getWeekEventsSlice.actions.delete(payload)); + yield put(getDayEventsSlice.actions.delete(payload)); yield put(eventsEntitiesSlice.actions.delete(payload)); const isInDb = !event?._id?.startsWith(ID_OPTIMISTIC_PREFIX); diff --git a/packages/web/src/ducks/events/sagas/saga.util.ts b/packages/web/src/ducks/events/sagas/saga.util.ts index 66dd184d2..1ac10a06b 100644 --- a/packages/web/src/ducks/events/sagas/saga.util.ts +++ b/packages/web/src/ducks/events/sagas/saga.util.ts @@ -19,6 +19,7 @@ import { validateGridEvent } from "@web/common/validators/grid.event.validator"; import { EventApi } from "@web/ducks/events/event.api"; import { Payload_ConvertEvent } from "@web/ducks/events/event.types"; import { selectEventById } from "@web/ducks/events/selectors/event.selectors"; +import { getDayEventsSlice } from "@web/ducks/events/slices/day.slice"; import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; import { getSomedayEventsSlice } from "@web/ducks/events/slices/someday.slice"; import { getWeekEventsSlice } from "@web/ducks/events/slices/week.slice"; @@ -53,6 +54,7 @@ export function* insertOptimisticEvent( yield put(getSomedayEventsSlice.actions.insert(event._id!)); } else { yield put(getWeekEventsSlice.actions.insert(event._id!)); + yield put(getDayEventsSlice.actions.insert(event._id!)); } yield put( eventsEntitiesSlice.actions.insert( @@ -111,6 +113,12 @@ export function* replaceOptimisticId(optimisticId: string, isSomeday: boolean) { newWeekId: _id, }), ); + yield put( + getDayEventsSlice.actions.replace({ + oldDayId: optimisticId, + newDayId: _id, + }), + ); } yield put( diff --git a/packages/web/src/views/Calendar/components/Event/styled.ts b/packages/web/src/views/Calendar/components/Event/styled.ts index 45cbaa47d..d1e239075 100644 --- a/packages/web/src/views/Calendar/components/Event/styled.ts +++ b/packages/web/src/views/Calendar/components/Event/styled.ts @@ -6,6 +6,7 @@ import { colorByPriority, hoverColorByPriority, } from "@web/common/styles/theme.util"; +import { getEventCursorStyle } from "@web/common/utils/event/event.util"; import { Text } from "@web/components/Text"; interface StyledEventProps { @@ -84,7 +85,7 @@ export const StyledEvent = styled.div.attrs((props) => { !isResizing && ` background-color: ${isOptimistic && backgroundColor ? darken(backgroundColor) : hoverColor}; - cursor: ${isDragging ? "move" : isOptimistic ? "wait" : "pointer"}; + cursor: ${getEventCursorStyle(isDragging, isOptimistic)}; drop-shadow(2px 4px 4px ${theme.color.shadow.default}); `}; } diff --git a/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx b/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx index 9cff23d4d..16c8e8bb3 100644 --- a/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx +++ b/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx @@ -1,4 +1,4 @@ -import React, { MouseEvent, memo } from "react"; +import { MouseEvent, memo } from "react"; import { Priorities } from "@core/constants/core.constants"; import dayjs from "@core/util/date/dayjs"; import { diff --git a/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent.tsx b/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent.tsx index 1c45dafa6..860273e24 100644 --- a/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent.tsx +++ b/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent.tsx @@ -2,14 +2,19 @@ import classNames from "classnames"; import fastDeepEqual from "fast-deep-equal/react"; import { memo } from "react"; import { UseInteractionsReturn } from "@floating-ui/react"; -import { Categories_Event } from "@core/types/event.types"; +import { Categories_Event, Schema_Event } from "@core/types/event.types"; import { CLASS_ALL_DAY_CALENDAR_EVENT } from "@web/common/constants/web.constants"; +import { useIsDraggingEvent } from "@web/common/hooks/useIsDraggingEvent"; import { useMainGridSelectionState } from "@web/common/hooks/useMainGridSelectionState"; import { CursorItem, useFloatingNodeIdAtCursor, } from "@web/common/hooks/useOpenAtCursor"; import { Schema_GridEvent } from "@web/common/types/web.event.types"; +import { + getEventCursorClass, + isOptimisticEvent, +} from "@web/common/utils/event/event.util"; import { Draggable } from "@web/components/DND/Draggable"; import { AllDayAgendaEvent } from "@web/views/Day/components/Agenda/Events/AllDayAgendaEvent/AllDayAgendaEvent"; import { useOpenAgendaEventPreview } from "@web/views/Day/hooks/events/useOpenAgendaEventPreview"; @@ -32,6 +37,9 @@ export const DraggableAllDayAgendaEvent = memo( const nodeId = useFloatingNodeIdAtCursor(); const { selecting } = useMainGridSelectionState(); const eventFormOpen = nodeId === CursorItem.EventForm; + const dragging = useIsDraggingEvent(); + const isOptimistic = isOptimisticEvent(event as Schema_Event); + const cursorClass = getEventCursorClass(dragging, isOptimistic); if (!event.startDate || !event.endDate || !event.isAllDay) return null; @@ -53,9 +61,10 @@ export const DraggableAllDayAgendaEvent = memo( as="div" className={classNames( CLASS_ALL_DAY_CALENDAR_EVENT, - "mx-2 cursor-move touch-none rounded last:mb-0.5", + "mx-2 touch-none rounded last:mb-0.5", "focus-visible:ring-2", "focus:outline-none focus-visible:ring-yellow-200", + cursorClass, { "pointer-events-none": selecting }, )} title={event.title} diff --git a/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.test.tsx b/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.test.tsx index edd7826d2..8f77951b2 100644 --- a/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.test.tsx +++ b/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.test.tsx @@ -1,5 +1,7 @@ +import { ObjectId } from "bson"; import "@testing-library/jest-dom"; import { fireEvent, screen } from "@testing-library/react"; +import { ID_OPTIMISTIC_PREFIX } from "@core/constants/core.constants"; import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory"; import { render } from "@web/__tests__/__mocks__/mock.render"; import { useEventResizeActions } from "@web/common/hooks/useEventResizeActions"; @@ -141,4 +143,20 @@ describe("DraggableTimedAgendaEvent", () => { fireEvent.contextMenu(eventElement); expect(mockOpenEventContextMenu).not.toHaveBeenCalled(); }); + + it("should render correctly with optimistic event", () => { + const optimisticId = `${ID_OPTIMISTIC_PREFIX}-${new ObjectId().toString()}`; + const optimisticEvent: Schema_GridEvent = { + ...baseEvent, + _id: optimisticId, + }; + + render( + , + ); + + const eventElement = screen.getByRole("button"); + expect(eventElement).toBeInTheDocument(); + expect(eventElement).toHaveAttribute("data-event-id", optimisticId); + }); }); diff --git a/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.tsx b/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.tsx index 2c90ea055..b9098dbc7 100644 --- a/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.tsx +++ b/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.tsx @@ -23,6 +23,10 @@ import { useResizeId } from "@web/common/hooks/useResizeId"; import { useResizing } from "@web/common/hooks/useResizing"; import { theme } from "@web/common/styles/theme"; import { Schema_GridEvent } from "@web/common/types/web.event.types"; +import { + getEventCursorClass, + isOptimisticEvent, +} from "@web/common/utils/event/event.util"; import { Draggable } from "@web/components/DND/Draggable"; import { Resizable } from "@web/components/DND/Resizable"; import { TimedAgendaEvent } from "@web/views/Day/components/Agenda/Events/TimedAgendaEvent/TimedAgendaEvent"; @@ -71,6 +75,9 @@ export const DraggableTimedAgendaEvent = memo( event as WithCompassId, ); + const isOptimistic = isOptimisticEvent(event as Schema_Event); + const cursorClass = getEventCursorClass(dragging, isOptimistic); + if (!_id || !event.startDate || !endDate || isAllDay) return null; return ( @@ -94,9 +101,10 @@ export const DraggableTimedAgendaEvent = memo( asChild className={classNames( CLASS_TIMED_CALENDAR_EVENT, - "absolute cursor-move touch-none rounded focus:outline-none", + "absolute touch-none rounded focus:outline-none", "focus-visible:rounded focus-visible:ring-2", "focus:outline-none focus-visible:ring-yellow-200", + cursorClass, { "pointer-events-none": isBeingSelected }, )} style={{ top: startPosition, height }} diff --git a/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.test.ts b/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.test.ts index 149bb7611..b626bc127 100644 --- a/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.test.ts +++ b/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.test.ts @@ -1,4 +1,6 @@ +import { ObjectId } from "bson"; import { renderHook } from "@testing-library/react"; +import { ID_OPTIMISTIC_PREFIX } from "@core/constants/core.constants"; import { DATA_EVENT_ELEMENT_ID } from "@web/common/constants/web.constants"; import { CursorItem, @@ -115,4 +117,35 @@ describe("useOpenAgendaEventPreview", () => { expect(setActiveEvent).not.toHaveBeenCalled(); expect(openFloatingAtCursor).not.toHaveBeenCalled(); }); + + it("should not open event preview if event is optimistic", () => { + const optimisticId = `${ID_OPTIMISTIC_PREFIX}-${new ObjectId().toString()}`; + const eventClass = "event-class"; + const mockEvent = { _id: optimisticId, title: "Optimistic Event" }; + const mockReference = { + getAttribute: jest.fn().mockReturnValue(optimisticId), + }; + const mockElement = { + closest: jest.fn().mockReturnValue(mockReference), + }; + const mockEventObj = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + currentTarget: mockElement, + }; + + (getEventClass as jest.Mock).mockReturnValue(eventClass); + (eventsStore.query as jest.Mock).mockReturnValue(mockEvent); + + const { result } = renderHook(() => useOpenAgendaEventPreview()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result.current(mockEventObj as any); + + expect(mockEventObj.preventDefault).toHaveBeenCalled(); + expect(mockEventObj.stopPropagation).toHaveBeenCalled(); + expect(eventsStore.query).toHaveBeenCalled(); + expect(setActiveEvent).not.toHaveBeenCalled(); + expect(openFloatingAtCursor).not.toHaveBeenCalled(); + }); }); diff --git a/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.ts b/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.ts index 0038456b5..81204552b 100644 --- a/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.ts +++ b/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.ts @@ -5,6 +5,7 @@ import { CursorItem, openFloatingAtCursor, } from "@web/common/hooks/useOpenAtCursor"; +import { isOptimisticEvent } from "@web/common/utils/event/event.util"; import { eventsStore, setActiveEvent } from "@web/store/events"; import { getEventClass } from "@web/views/Day/util/agenda/focus.util"; @@ -26,6 +27,8 @@ export function useOpenAgendaEventPreview() { if (!draftEvent) return; + if (isOptimisticEvent(draftEvent)) return; + setActiveEvent(draftEvent._id); openFloatingAtCursor({ nodeId, placement: "right", reference }); }, diff --git a/packages/web/src/views/Forms/hooks/useOpenEventForm.test.ts b/packages/web/src/views/Forms/hooks/useOpenEventForm.test.ts index cb1b3c5a0..dfbeeb0ec 100644 --- a/packages/web/src/views/Forms/hooks/useOpenEventForm.test.ts +++ b/packages/web/src/views/Forms/hooks/useOpenEventForm.test.ts @@ -1,6 +1,11 @@ +import { ObjectId } from "bson"; import { act } from "react"; import { renderHook } from "@testing-library/react"; -import { Origin, Priorities } from "@core/constants/core.constants"; +import { + ID_OPTIMISTIC_PREFIX, + Origin, + Priorities, +} from "@core/constants/core.constants"; import dayjs from "@core/util/date/dayjs"; import { CLASS_TIMED_CALENDAR_EVENT, @@ -32,6 +37,9 @@ jest.mock("@web/store/events", () => ({ })); jest.mock("@web/common/utils/event/event.util", () => ({ getCalendarEventElementFromGrid: jest.fn(), + isOptimisticEvent: jest.fn((event) => { + return event._id?.startsWith("optimistic-") || false; + }), })); describe("useOpenEventForm", () => { @@ -180,4 +188,35 @@ describe("useOpenEventForm", () => { expect(mockSetDraft).toHaveBeenCalledWith(mockEvent); }); + + it("should not open event form for editing if event is optimistic", async () => { + const optimisticId = `${ID_OPTIMISTIC_PREFIX}-${new ObjectId().toString()}`; + const mockEventElement = document.createElement("div"); + mockEventElement.classList.add(CLASS_TIMED_CALENDAR_EVENT); + mockEventElement.setAttribute(DATA_EVENT_ELEMENT_ID, optimisticId); + + const mockEvent = { + _id: optimisticId, + title: "Optimistic Event", + }; + + getElementAtPoint.mockReturnValue(mockEventElement); + getEventClass.mockReturnValue(CLASS_TIMED_CALENDAR_EVENT); + (eventsStore.query as jest.Mock).mockReturnValue(mockEvent); + + const { result } = renderHook(useOpenEventForm); + + await act(async () => { + await result.current( + new CustomEvent("click", { + bubbles: true, + detail: { create: false, id: optimisticId }, + }) as unknown as React.PointerEvent, + ); + }); + + expect(eventsStore.query).toHaveBeenCalled(); + expect(mockSetDraft).not.toHaveBeenCalled(); + expect(openFloatingAtCursor).not.toHaveBeenCalled(); + }); }); diff --git a/packages/web/src/views/Forms/hooks/useOpenEventForm.ts b/packages/web/src/views/Forms/hooks/useOpenEventForm.ts index fa9565050..a10954b46 100644 --- a/packages/web/src/views/Forms/hooks/useOpenEventForm.ts +++ b/packages/web/src/views/Forms/hooks/useOpenEventForm.ts @@ -23,7 +23,10 @@ import { openFloatingAtCursor, } from "@web/common/hooks/useOpenAtCursor"; import { getElementAtPoint } from "@web/common/utils/dom/event-emitter.util"; -import { getCalendarEventElementFromGrid } from "@web/common/utils/event/event.util"; +import { + getCalendarEventElementFromGrid, + isOptimisticEvent, +} from "@web/common/utils/event/event.util"; import { eventsStore, getDraft, setDraft } from "@web/store/events"; import { useDateInView } from "@web/views/Day/hooks/navigation/useDateInView"; import { @@ -73,6 +76,9 @@ export function useOpenEventForm() { if (existingEventId && !create) { draftEvent = eventsStore.query(getEntity(existingEventId)); + if (draftEvent && isOptimisticEvent(draftEvent)) { + return; + } } if (!draftEvent) {