Skip to content

Commit 86d6c53

Browse files
Copilottyler-dane
andauthored
feat(web): add optimistic rendering for day view event creation (#1395)
* Initial plan * Add optimistic rendering support for day view events Co-authored-by: tyler-dane <30163055+tyler-dane@users.noreply.github.com> * test(events): add unit tests for optimistic rendering in event saga - Introduced comprehensive tests for the event saga to validate optimistic rendering behavior. - Ensured that events are correctly added to the state with optimistic IDs during creation. - Verified that events remain in the state during API calls and transition from optimistic to real IDs upon successful API responses. - Included checks for maintaining event presence in week and day event lists throughout the creation process. * test(events): enhance unit tests for optimistic rendering in event saga - Updated tests to utilize Schema_Event for event parsing during optimistic rendering. - Refactored event creation logic in tests to improve clarity and maintainability. - Ensured consistent handling of optimistic IDs and real IDs throughout the event lifecycle. * test(events): enhance tests for optimistic event handling - Updated tests to include checks for optimistic events in `DraggableTimedAgendaEvent`, ensuring correct rendering and behavior. - Added logic to prevent opening event previews and forms for optimistic events in `useOpenAgendaEventPreview` and `useOpenEventForm`. - Refactored event utility functions to streamline optimistic event checks across components. * feat(events): add cursor style utility for event dragging - Introduced `getEventCursorStyle` utility function to determine cursor styles based on dragging and optimistic event states. - Updated `StyledEvent` component to utilize the new cursor style utility for improved cursor handling during event interactions. - Refactored `DraggableAllDayAgendaEvent` and `DraggableTimedAgendaEvent` components to apply the cursor styles dynamically based on event states. * feat(events): introduce getEventCursorClass utility for cursor management - Added `getEventCursorClass` function to determine cursor classes based on dragging and optimistic event states. - Updated `DraggableAllDayAgendaEvent` and `DraggableTimedAgendaEvent` components to utilize the new cursor class utility for improved cursor handling during event interactions. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tyler-dane <30163055+tyler-dane@users.noreply.github.com> Co-authored-by: Tyler Dane <tyler@switchback.tech>
1 parent bfee91a commit 86d6c53

13 files changed

Lines changed: 415 additions & 7 deletions

File tree

packages/web/src/common/utils/event/event.util.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,24 @@ export const isOptimisticEvent = (event: Schema_Event) => {
253253
return isOptimistic;
254254
};
255255

256+
export const getEventCursorStyle = (
257+
isDragging: boolean,
258+
isOptimistic: boolean,
259+
): string => {
260+
if (isDragging) return "move";
261+
if (isOptimistic) return "wait";
262+
return "pointer";
263+
};
264+
265+
export const getEventCursorClass = (
266+
isDragging: boolean,
267+
isOptimistic: boolean,
268+
): string => {
269+
if (isDragging) return "cursor-move";
270+
if (isOptimistic) return "cursor-wait";
271+
return "cursor-pointer";
272+
};
273+
256274
export const prepEvtAfterDraftDrop = (
257275
category: Categories_Event,
258276
dropItem: DropResult & Schema_Event,
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { AxiosResponse } from "axios";
2+
import { ID_OPTIMISTIC_PREFIX } from "@core/constants/core.constants";
3+
import { Schema_Event } from "@core/types/event.types";
4+
import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory";
5+
import { createStoreWithEvents } from "@web/__tests__/utils/state/store.test.util";
6+
import { sagaMiddleware } from "@web/common/store/middlewares";
7+
import { Schema_GridEvent } from "@web/common/types/web.event.types";
8+
import { isOptimisticEvent } from "@web/common/utils/event/event.util";
9+
import { EventApi } from "@web/ducks/events/event.api";
10+
import { selectEventById } from "@web/ducks/events/selectors/event.selectors";
11+
import { createEventSlice } from "@web/ducks/events/slices/event.slice";
12+
import { RootState } from "@web/store";
13+
import { sagas } from "@web/store/sagas";
14+
import { OnSubmitParser } from "@web/views/Calendar/components/Draft/hooks/actions/submit.parser";
15+
16+
jest.mock("@web/ducks/events/event.api");
17+
18+
describe("createEvent saga - optimistic rendering", () => {
19+
let store: ReturnType<typeof createStoreWithEvents>;
20+
let mockCreateApi: jest.SpyInstance;
21+
22+
beforeEach(() => {
23+
jest.clearAllMocks();
24+
store = createStoreWithEvents([]);
25+
sagaMiddleware.run(sagas);
26+
27+
mockCreateApi = jest.spyOn(EventApi, "create").mockImplementation(() => {
28+
return Promise.resolve({
29+
status: 200,
30+
} as unknown as AxiosResponse<void>);
31+
});
32+
});
33+
34+
it("should immediately add event with optimistic ID when created", () => {
35+
const gridEvent = createMockStandaloneEvent() as Schema_GridEvent;
36+
const event = new OnSubmitParser(gridEvent).parse() as Schema_Event;
37+
const action = createEventSlice.actions.request(event);
38+
39+
store.dispatch(action);
40+
41+
// Get all events from state
42+
const state = store.getState();
43+
const eventEntities = state.events.entities.value || {};
44+
const eventIds = Object.keys(eventEntities);
45+
46+
// Should have exactly one event
47+
expect(eventIds).toHaveLength(1);
48+
49+
const optimisticId = eventIds[0];
50+
const optimisticEvent = eventEntities[optimisticId];
51+
52+
// Event should have optimistic ID prefix
53+
expect(optimisticId).toMatch(new RegExp(`^${ID_OPTIMISTIC_PREFIX}-`));
54+
expect(optimisticEvent._id).toBe(optimisticId);
55+
expect(isOptimisticEvent(optimisticEvent)).toBe(true);
56+
57+
// Event should be in week and day event lists
58+
const weekEventIds = state.events.getWeekEvents.value?.data || [];
59+
const dayEventIds = state.events.getDayEvents.value?.data || [];
60+
61+
expect(weekEventIds).toContain(optimisticId);
62+
expect(dayEventIds).toContain(optimisticId);
63+
});
64+
65+
it("should keep event in state during API call", async () => {
66+
// Create a promise that we can control
67+
let resolveApiCall: (value: AxiosResponse<void>) => void;
68+
const apiPromise = new Promise<AxiosResponse<void>>((resolve) => {
69+
resolveApiCall = resolve;
70+
});
71+
72+
mockCreateApi.mockImplementation(() => apiPromise);
73+
74+
const gridEvent = createMockStandaloneEvent() as Schema_GridEvent;
75+
const event = new OnSubmitParser(gridEvent).parse() as Schema_Event;
76+
const action = createEventSlice.actions.request(event);
77+
78+
store.dispatch(action);
79+
80+
// Get the optimistic ID immediately after dispatch
81+
const stateAfterDispatch = store.getState();
82+
const eventEntitiesAfterDispatch =
83+
stateAfterDispatch.events.entities.value || {};
84+
const optimisticIds = Object.keys(eventEntitiesAfterDispatch);
85+
expect(optimisticIds).toHaveLength(1);
86+
const optimisticId = optimisticIds[0];
87+
88+
// Verify event is still in state while API call is pending
89+
const stateDuringApiCall = store.getState();
90+
const eventDuringApiCall = selectEventById(
91+
stateDuringApiCall as RootState,
92+
optimisticId,
93+
);
94+
95+
expect(eventDuringApiCall).not.toBeNull();
96+
expect(eventDuringApiCall?._id).toBe(optimisticId);
97+
expect(isOptimisticEvent(eventDuringApiCall!)).toBe(true);
98+
99+
// Verify event is still in week and day lists
100+
const weekEventIdsDuringCall =
101+
stateDuringApiCall.events.getWeekEvents.value?.data || [];
102+
const dayEventIdsDuringCall =
103+
stateDuringApiCall.events.getDayEvents.value?.data || [];
104+
105+
expect(weekEventIdsDuringCall).toContain(optimisticId);
106+
expect(dayEventIdsDuringCall).toContain(optimisticId);
107+
108+
// Resolve the API call
109+
resolveApiCall!({
110+
status: 200,
111+
} as AxiosResponse<void>);
112+
113+
// Wait for saga to complete
114+
await new Promise((resolve) => setTimeout(resolve, 100));
115+
116+
// Verify event is still in state after API call completes
117+
// The optimistic ID should be replaced with real ID
118+
const realEventId = optimisticId.replace(`${ID_OPTIMISTIC_PREFIX}-`, "");
119+
const stateAfterApiCall = store.getState();
120+
const eventAfterApiCall = selectEventById(
121+
stateAfterApiCall as RootState,
122+
realEventId,
123+
);
124+
125+
// Event should still exist with real ID
126+
expect(eventAfterApiCall).not.toBeNull();
127+
expect(eventAfterApiCall?._id).toBe(realEventId);
128+
});
129+
130+
it("should replace optimistic ID with real ID after successful API call", async () => {
131+
const gridEvent = createMockStandaloneEvent() as Schema_GridEvent;
132+
const event = new OnSubmitParser(gridEvent).parse() as Schema_Event;
133+
134+
// Mock API to return success
135+
mockCreateApi.mockResolvedValue({
136+
status: 200,
137+
} as AxiosResponse<void>);
138+
139+
const action = createEventSlice.actions.request(event);
140+
store.dispatch(action);
141+
142+
// Get optimistic ID immediately
143+
const initialState = store.getState();
144+
const initialEventEntities = initialState.events.entities.value || {};
145+
const optimisticIds = Object.keys(initialEventEntities);
146+
expect(optimisticIds).toHaveLength(1);
147+
const optimisticId = optimisticIds[0];
148+
149+
// The real ID is the optimistic ID without the prefix
150+
const realEventId = optimisticId.replace(`${ID_OPTIMISTIC_PREFIX}-`, "");
151+
152+
// Wait for saga to complete
153+
await new Promise((resolve) => setTimeout(resolve, 100));
154+
155+
const finalState = store.getState();
156+
const eventEntities = finalState.events.entities.value || {};
157+
const eventIds = Object.keys(eventEntities);
158+
159+
// Should still have exactly one event
160+
expect(eventIds).toHaveLength(1);
161+
162+
// The event should have the real ID (without optimistic prefix)
163+
expect(eventIds[0]).toBe(realEventId);
164+
expect(eventIds[0]).not.toMatch(new RegExp(`^${ID_OPTIMISTIC_PREFIX}-`));
165+
166+
// Verify the event is accessible by real ID
167+
const finalEvent = selectEventById(finalState as RootState, realEventId);
168+
expect(finalEvent).not.toBeNull();
169+
expect(finalEvent?._id).toBe(realEventId);
170+
expect(isOptimisticEvent(finalEvent!)).toBe(false);
171+
});
172+
173+
it("should never remove event from state after being added", async () => {
174+
const gridEvent = createMockStandaloneEvent() as Schema_GridEvent;
175+
const event = new OnSubmitParser(gridEvent).parse() as Schema_Event;
176+
const action = createEventSlice.actions.request(event);
177+
178+
store.dispatch(action);
179+
180+
// Get optimistic ID immediately
181+
const initialState = store.getState();
182+
const initialEventEntities = initialState.events.entities.value || {};
183+
const optimisticIds = Object.keys(initialEventEntities);
184+
expect(optimisticIds).toHaveLength(1);
185+
const optimisticId = optimisticIds[0];
186+
const realEventId = optimisticId.replace(`${ID_OPTIMISTIC_PREFIX}-`, "");
187+
188+
// Check 1: Immediately after dispatch (should have optimistic ID)
189+
const check1 = store.getState();
190+
const event1 = selectEventById(check1 as RootState, optimisticId);
191+
expect(event1).not.toBeNull();
192+
expect(event1?._id).toBe(optimisticId);
193+
194+
// Wait for API call to complete
195+
await new Promise((resolve) => setTimeout(resolve, 100));
196+
197+
// Check 2: After API call completes (should have real ID, not optimistic)
198+
const check2 = store.getState();
199+
const event2Optimistic = selectEventById(check2 as RootState, optimisticId);
200+
const event2Real = selectEventById(check2 as RootState, realEventId);
201+
202+
// Event should no longer be accessible by optimistic ID
203+
expect(event2Optimistic).toBeNull();
204+
// But should be accessible by real ID
205+
expect(event2Real).not.toBeNull();
206+
expect(event2Real?._id).toBe(realEventId);
207+
208+
// Final verification: event should exist with real ID
209+
const finalState = store.getState();
210+
const finalEventEntities = finalState.events.entities.value || {};
211+
const finalEventCount = Object.keys(finalEventEntities).length;
212+
213+
// Should have exactly one event
214+
expect(finalEventCount).toBe(1);
215+
expect(finalEventEntities[realEventId]).toBeDefined();
216+
217+
// Verify event count never dropped to zero
218+
// The event should transition from optimistic ID to real ID without disappearing
219+
expect(finalEventCount).toBeGreaterThanOrEqual(1);
220+
});
221+
222+
it("should maintain event in week and day event lists throughout creation process", async () => {
223+
const gridEvent = createMockStandaloneEvent() as Schema_GridEvent;
224+
const event = new OnSubmitParser(gridEvent).parse() as Schema_Event;
225+
const action = createEventSlice.actions.request(event);
226+
227+
store.dispatch(action);
228+
229+
// Get optimistic ID
230+
const initialState = store.getState();
231+
const initialEventEntities = initialState.events.entities.value || {};
232+
const optimisticIds = Object.keys(initialEventEntities);
233+
expect(optimisticIds).toHaveLength(1);
234+
const optimisticId = optimisticIds[0];
235+
const realEventId = optimisticId.replace(`${ID_OPTIMISTIC_PREFIX}-`, "");
236+
237+
// Verify in lists immediately with optimistic ID
238+
const initialWeekIds = initialState.events.getWeekEvents.value?.data || [];
239+
const initialDayIds = initialState.events.getDayEvents.value?.data || [];
240+
expect(initialWeekIds).toContain(optimisticId);
241+
expect(initialDayIds).toContain(optimisticId);
242+
243+
// Wait for API call to complete
244+
await new Promise((resolve) => setTimeout(resolve, 100));
245+
246+
// Verify still in lists but with real ID (replaced)
247+
const finalState = store.getState();
248+
const finalWeekIds = finalState.events.getWeekEvents.value?.data || [];
249+
const finalDayIds = finalState.events.getDayEvents.value?.data || [];
250+
251+
// Should no longer have optimistic ID
252+
expect(finalWeekIds).not.toContain(optimisticId);
253+
expect(finalDayIds).not.toContain(optimisticId);
254+
255+
// Should have real ID in both lists
256+
expect(finalWeekIds).toContain(realEventId);
257+
expect(finalDayIds).toContain(realEventId);
258+
259+
// Should have exactly one event in each list
260+
expect(finalWeekIds).toHaveLength(1);
261+
expect(finalDayIds).toHaveLength(1);
262+
});
263+
});

packages/web/src/ducks/events/sagas/event.sagas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export function* convertCalendarToSomedayEvent({
7272
}
7373

7474
yield put(getWeekEventsSlice.actions.insert(payload.event._id!));
75+
yield put(getDayEventsSlice.actions.insert(payload.event._id!));
7576
yield put(editEventSlice.actions.error());
7677

7778
handleError(error as Error);
@@ -103,6 +104,7 @@ export function* deleteEvent({ payload }: Action_DeleteEvent) {
103104

104105
try {
105106
yield put(getWeekEventsSlice.actions.delete(payload));
107+
yield put(getDayEventsSlice.actions.delete(payload));
106108
yield put(eventsEntitiesSlice.actions.delete(payload));
107109

108110
const isInDb = !event?._id?.startsWith(ID_OPTIMISTIC_PREFIX);

packages/web/src/ducks/events/sagas/saga.util.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { validateGridEvent } from "@web/common/validators/grid.event.validator";
1919
import { EventApi } from "@web/ducks/events/event.api";
2020
import { Payload_ConvertEvent } from "@web/ducks/events/event.types";
2121
import { selectEventById } from "@web/ducks/events/selectors/event.selectors";
22+
import { getDayEventsSlice } from "@web/ducks/events/slices/day.slice";
2223
import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice";
2324
import { getSomedayEventsSlice } from "@web/ducks/events/slices/someday.slice";
2425
import { getWeekEventsSlice } from "@web/ducks/events/slices/week.slice";
@@ -53,6 +54,7 @@ export function* insertOptimisticEvent(
5354
yield put(getSomedayEventsSlice.actions.insert(event._id!));
5455
} else {
5556
yield put(getWeekEventsSlice.actions.insert(event._id!));
57+
yield put(getDayEventsSlice.actions.insert(event._id!));
5658
}
5759
yield put(
5860
eventsEntitiesSlice.actions.insert(
@@ -111,6 +113,12 @@ export function* replaceOptimisticId(optimisticId: string, isSomeday: boolean) {
111113
newWeekId: _id,
112114
}),
113115
);
116+
yield put(
117+
getDayEventsSlice.actions.replace({
118+
oldDayId: optimisticId,
119+
newDayId: _id,
120+
}),
121+
);
114122
}
115123

116124
yield put(

packages/web/src/views/Calendar/components/Event/styled.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
colorByPriority,
77
hoverColorByPriority,
88
} from "@web/common/styles/theme.util";
9+
import { getEventCursorStyle } from "@web/common/utils/event/event.util";
910
import { Text } from "@web/components/Text";
1011

1112
interface StyledEventProps {
@@ -84,7 +85,7 @@ export const StyledEvent = styled.div.attrs<StyledEventProps>((props) => {
8485
!isResizing &&
8586
`
8687
background-color: ${isOptimistic && backgroundColor ? darken(backgroundColor) : hoverColor};
87-
cursor: ${isDragging ? "move" : isOptimistic ? "wait" : "pointer"};
88+
cursor: ${getEventCursorStyle(isDragging, isOptimistic)};
8889
drop-shadow(2px 4px 4px ${theme.color.shadow.default});
8990
`};
9091
}

packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { MouseEvent, memo } from "react";
1+
import { MouseEvent, memo } from "react";
22
import { Priorities } from "@core/constants/core.constants";
33
import dayjs from "@core/util/date/dayjs";
44
import {

packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@ import classNames from "classnames";
22
import fastDeepEqual from "fast-deep-equal/react";
33
import { memo } from "react";
44
import { UseInteractionsReturn } from "@floating-ui/react";
5-
import { Categories_Event } from "@core/types/event.types";
5+
import { Categories_Event, Schema_Event } from "@core/types/event.types";
66
import { CLASS_ALL_DAY_CALENDAR_EVENT } from "@web/common/constants/web.constants";
7+
import { useIsDraggingEvent } from "@web/common/hooks/useIsDraggingEvent";
78
import { useMainGridSelectionState } from "@web/common/hooks/useMainGridSelectionState";
89
import {
910
CursorItem,
1011
useFloatingNodeIdAtCursor,
1112
} from "@web/common/hooks/useOpenAtCursor";
1213
import { Schema_GridEvent } from "@web/common/types/web.event.types";
14+
import {
15+
getEventCursorClass,
16+
isOptimisticEvent,
17+
} from "@web/common/utils/event/event.util";
1318
import { Draggable } from "@web/components/DND/Draggable";
1419
import { AllDayAgendaEvent } from "@web/views/Day/components/Agenda/Events/AllDayAgendaEvent/AllDayAgendaEvent";
1520
import { useOpenAgendaEventPreview } from "@web/views/Day/hooks/events/useOpenAgendaEventPreview";
@@ -32,6 +37,9 @@ export const DraggableAllDayAgendaEvent = memo(
3237
const nodeId = useFloatingNodeIdAtCursor();
3338
const { selecting } = useMainGridSelectionState();
3439
const eventFormOpen = nodeId === CursorItem.EventForm;
40+
const dragging = useIsDraggingEvent();
41+
const isOptimistic = isOptimisticEvent(event as Schema_Event);
42+
const cursorClass = getEventCursorClass(dragging, isOptimistic);
3543

3644
if (!event.startDate || !event.endDate || !event.isAllDay) return null;
3745

@@ -53,9 +61,10 @@ export const DraggableAllDayAgendaEvent = memo(
5361
as="div"
5462
className={classNames(
5563
CLASS_ALL_DAY_CALENDAR_EVENT,
56-
"mx-2 cursor-move touch-none rounded last:mb-0.5",
64+
"mx-2 touch-none rounded last:mb-0.5",
5765
"focus-visible:ring-2",
5866
"focus:outline-none focus-visible:ring-yellow-200",
67+
cursorClass,
5968
{ "pointer-events-none": selecting },
6069
)}
6170
title={event.title}

0 commit comments

Comments
 (0)