Skip to content

Commit d66df3e

Browse files
committed
fix(web): keep all-day drafts visible
1 parent d787868 commit d66df3e

9 files changed

Lines changed: 306 additions & 13 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Origin, Priorities } from "@core/constants/core.constants";
2+
import { type Schema_GridEvent } from "@web/common/types/web.event.types";
3+
import { gridEventDefaultPosition } from "@web/common/utils/event/event.util";
4+
import { positionAllDayDraftEvent } from "./allDayDraftEventPosition";
5+
import { describe, expect, it } from "bun:test";
6+
7+
const createAllDayEvent = (
8+
overrides: Partial<Schema_GridEvent> = {},
9+
): Schema_GridEvent => ({
10+
_id: "event-1",
11+
endDate: "2026-05-26",
12+
isAllDay: true,
13+
isSomeday: false,
14+
origin: Origin.COMPASS,
15+
position: gridEventDefaultPosition,
16+
priority: Priorities.UNASSIGNED,
17+
startDate: "2026-05-25",
18+
title: "All-day event",
19+
user: "user-1",
20+
...overrides,
21+
});
22+
23+
describe("positionAllDayDraftEvent", () => {
24+
it("places a new all-day draft after existing same-day all-day events", () => {
25+
const draft = createAllDayEvent({
26+
_id: undefined,
27+
title: "Draft",
28+
});
29+
30+
const { activeDraftEvent } = positionAllDayDraftEvent({
31+
draft,
32+
events: [
33+
createAllDayEvent({
34+
_id: "first",
35+
title: "First",
36+
}),
37+
createAllDayEvent({
38+
_id: "second",
39+
title: "Second",
40+
}),
41+
],
42+
});
43+
44+
expect(activeDraftEvent?.row).toBe(3);
45+
});
46+
47+
it("replaces an existing all-day event draft before assigning rows", () => {
48+
const draft = createAllDayEvent({
49+
_id: "second",
50+
title: "Editing second",
51+
});
52+
53+
const { activeDraftEvent } = positionAllDayDraftEvent({
54+
draft,
55+
events: [
56+
createAllDayEvent({
57+
_id: "first",
58+
title: "First",
59+
}),
60+
createAllDayEvent({
61+
_id: "second",
62+
title: "Second",
63+
}),
64+
],
65+
});
66+
67+
expect(activeDraftEvent?.row).toBe(2);
68+
expect(activeDraftEvent?.title).toBe("Editing second");
69+
});
70+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { type Schema_Event } from "@core/types/event.types";
2+
import { type Schema_GridEvent } from "@web/common/types/web.event.types";
3+
import {
4+
assembleGridEvent,
5+
hasEventDates,
6+
} from "@web/common/utils/event/event.util";
7+
import { assignEventsToRow } from "@web/common/utils/grid/assign.row";
8+
9+
export const positionAllDayDraftEvent = ({
10+
draft,
11+
events,
12+
}: {
13+
draft: Schema_Event | null;
14+
events: Schema_GridEvent[];
15+
}): {
16+
activeDraftEvent: Schema_GridEvent | null;
17+
events: Schema_GridEvent[];
18+
} => {
19+
if (!draft?.isAllDay || !hasEventDates(draft)) {
20+
return { activeDraftEvent: null, events };
21+
}
22+
23+
const draftEvent = assembleGridEvent(draft);
24+
const existingIndex = draftEvent._id
25+
? events.findIndex((event) => event._id === draftEvent._id)
26+
: -1;
27+
const eventForRows =
28+
existingIndex === -1
29+
? draftEvent
30+
: {
31+
...draftEvent,
32+
position: events[existingIndex].position,
33+
row: events[existingIndex].row,
34+
};
35+
const eventsWithDraft =
36+
existingIndex === -1
37+
? [...events, eventForRows]
38+
: events.map((event, index) =>
39+
index === existingIndex ? eventForRows : event,
40+
);
41+
const positionedEvents = assignEventsToRow(eventsWithDraft).allDayEvents;
42+
const activeDraftIndex =
43+
existingIndex === -1 ? positionedEvents.length - 1 : existingIndex;
44+
45+
return {
46+
activeDraftEvent: positionedEvents[activeDraftIndex] ?? null,
47+
events: positionedEvents,
48+
};
49+
};

packages/web/src/components/DatePicker/DatePicker.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export const DatePicker: React.FC<Props> = (datePickerProps) => {
6868

6969
return (
7070
<ReactDatePicker
71-
popperClassName="!z-20"
71+
popperClassName="!z-22"
7272
calendarClassName={classNames("calendar", calendarClassName, {
7373
"calendar--open": isOpen,
7474
"calendar--animation": animationOnToggle,

packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,22 @@ const createTimedEvent = (
123123
...overrides,
124124
}) as Schema_Event;
125125

126+
const createAllDayEvent = (
127+
overrides: Partial<Schema_Event> & {
128+
_id: string;
129+
startDate: string;
130+
endDate: string;
131+
},
132+
): Schema_Event =>
133+
({
134+
isAllDay: true,
135+
isSomeday: false,
136+
recurrence: undefined,
137+
title: overrides._id,
138+
user: "user",
139+
...overrides,
140+
}) as Schema_Event;
141+
126142
const setDayEvents = (events: Schema_Event[]) => {
127143
store = createStoreWithEvents(events);
128144
};
@@ -137,6 +153,18 @@ const setDraftEvent = (event: Schema_Event) => {
137153
store.dispatch(draftSlice.actions.startGridClick(event));
138154
};
139155

156+
const getDismissOptions = () => {
157+
const [, options] = useDismissMock.mock.calls.at(-1) ?? [];
158+
159+
expect(options).toBeDefined();
160+
161+
return options as {
162+
enabled: boolean;
163+
outsidePress?: (event: MouseEvent) => boolean;
164+
outsidePressEvent: string;
165+
};
166+
};
167+
140168
beforeEach(() => {
141169
store = createStoreWithEvents([]);
142170
useDismissMock.mockClear();
@@ -369,15 +397,43 @@ describe("DayCalendarGrid", () => {
369397
it("dismisses the floating form after empty agenda mouse handlers run", () => {
370398
renderDayCalendarGrid();
371399

372-
expect(useDismissMock).toHaveBeenCalledWith(
373-
expect.anything(),
400+
expect(getDismissOptions()).toEqual(
374401
expect.objectContaining({
375402
enabled: true,
403+
outsidePress: expect.any(Function),
376404
outsidePressEvent: "click",
377405
}),
378406
);
379407
});
380408

409+
it("does not dismiss a new all-day draft from the click that created it", async () => {
410+
renderDayCalendarGrid();
411+
412+
const allDayRegion = screen.getByRole("region", { name: "All-day events" });
413+
414+
fireEvent.mouseDown(allDayRegion, {
415+
button: 0,
416+
clientX: 100,
417+
clientY: 1,
418+
});
419+
420+
await waitFor(() => {
421+
expect(getDraft()?.isAllDay).toBe(true);
422+
});
423+
424+
const outsidePress = getDismissOptions().outsidePress;
425+
expect(outsidePress).toBeDefined();
426+
427+
const creationClick = new MouseEvent("click", { bubbles: true });
428+
Object.defineProperty(creationClick, "target", {
429+
configurable: true,
430+
value: allDayRegion,
431+
});
432+
433+
expect(outsidePress?.(creationClick)).toBe(false);
434+
expect(outsidePress?.(creationClick)).toBe(true);
435+
});
436+
381437
it("dismisses an open draft when clicking empty Day all-day calendar space", () => {
382438
const existingDraft = createTimedEvent({
383439
_id: "open-all-day-draft",
@@ -401,6 +457,52 @@ describe("DayCalendarGrid", () => {
401457
expect(getDraft()).toBeNull();
402458
});
403459

460+
it("places a new all-day draft below existing all-day events", async () => {
461+
setDayEvents([
462+
createAllDayEvent({
463+
_id: "first-all-day",
464+
endDate: "2026-05-21",
465+
startDate: "2026-05-20",
466+
title: "First all-day",
467+
}),
468+
createAllDayEvent({
469+
_id: "second-all-day",
470+
endDate: "2026-05-21",
471+
startDate: "2026-05-20",
472+
title: "Second all-day",
473+
}),
474+
]);
475+
renderDayCalendarGrid();
476+
477+
fireEvent.mouseDown(
478+
screen.getByRole("region", { name: "All-day events" }),
479+
{
480+
button: 0,
481+
clientX: 100,
482+
clientY: 80,
483+
},
484+
);
485+
486+
await waitFor(() => {
487+
const draft = screen.getByRole("button", {
488+
name: /all-day event: untitled event/i,
489+
});
490+
const first = screen.getByRole("button", {
491+
name: /all-day event: first all-day/i,
492+
});
493+
const second = screen.getByRole("button", {
494+
name: /all-day event: second all-day/i,
495+
});
496+
497+
expect(parseFloat(draft.style.top)).toBeGreaterThan(
498+
parseFloat(first.style.top),
499+
);
500+
expect(parseFloat(draft.style.top)).toBeGreaterThan(
501+
parseFloat(second.style.top),
502+
);
503+
});
504+
});
505+
404506
it("scrolls the Day timed grid to now when the Day view requests it", () => {
405507
const scroll = mock();
406508

packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,6 @@ import {
5757
import { useDayTimedDraftCreation } from "./useDayTimedDraftCreation";
5858

5959
const isDayInteractionMotionActive = () => false;
60-
const DAY_EVENT_FORM_DISMISS_OPTIONS = {
61-
enabled: true,
62-
outsidePressEvent: "click",
63-
} as const;
6460

6561
export function DayCalendarGrid() {
6662
const dispatch = useAppDispatch();
@@ -87,14 +83,34 @@ export function DayCalendarGrid() {
8783
const dayEvents = useAppSelector(selectDayEvents);
8884
const allDayRowsCount = useAppSelector(selectDayRowCount);
8985
const draft = useAppSelector(selectDraft);
86+
const allDayCreationPressTargetRef = useRef<HTMLElement | null>(null);
9087
const floating = useFloatingAtCursor((open, _event, reason) => {
9188
const dismissed = reason === "escape-key" || reason === "outside-press";
9289

9390
if (!open && dismissed && nodeId$.getValue() === CursorItem.EventForm) {
9491
dispatch(draftSlice.actions.discard(undefined));
9592
}
9693
});
97-
const dismiss = useDismiss(floating.context, DAY_EVENT_FORM_DISMISS_OPTIONS);
94+
const shouldDismissEventForm = useCallback((event: MouseEvent) => {
95+
const allDayCreationPressTarget = allDayCreationPressTargetRef.current;
96+
97+
if (!allDayCreationPressTarget) {
98+
return true;
99+
}
100+
101+
allDayCreationPressTargetRef.current = null;
102+
103+
const target = event.target;
104+
105+
return !(
106+
target instanceof Node && allDayCreationPressTarget.contains(target)
107+
);
108+
}, []);
109+
const dismiss = useDismiss(floating.context, {
110+
enabled: true,
111+
outsidePress: shouldDismissEventForm,
112+
outsidePressEvent: "click",
113+
});
98114
const interactions = useInteractions([dismiss]);
99115

100116
const getDayInteractionLayoutSources = useCallback(
@@ -205,7 +221,10 @@ export function DayCalendarGrid() {
205221
return;
206222
}
207223

224+
const allDayCreationPressTarget = event.currentTarget;
225+
208226
if (draft) {
227+
allDayCreationPressTargetRef.current = null;
209228
dispatch(draftSlice.actions.discard(undefined));
210229
closeFloatingAtCursor();
211230
return;
@@ -222,6 +241,7 @@ export function DayCalendarGrid() {
222241
endDate,
223242
);
224243

244+
allDayCreationPressTargetRef.current = allDayCreationPressTarget;
225245
openEventFormForEvent(
226246
addId(assembleGridEvent(draftEvent as EventWithDates)),
227247
);
@@ -267,7 +287,7 @@ export function DayCalendarGrid() {
267287
return (
268288
<section
269289
aria-label="Calendar agenda"
270-
className="flex h-full min-w-xs flex-1 flex-col bg-bg-primary p-0.5"
290+
className="flex h-full min-w-xs flex-1 flex-col bg-bg-primary px-0.5 pb-0.5"
271291
onContextMenu={handleContextMenu}
272292
>
273293
{anchorElement}

packages/web/src/views/Day/components/Calendar/dayCalendarDraft.util.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type Schema_Event } from "@core/types/event.types";
22
import dayjs from "@core/util/date/dayjs";
3+
import { positionAllDayDraftEvent } from "@web/common/calendar-grid/layout/allDayDraftEventPosition";
34
import { type CalendarGridVisibleDate } from "@web/common/calendar-grid/types/calendarGrid.types";
45
import { type Schema_GridEvent } from "@web/common/types/web.event.types";
56
import {
@@ -28,6 +29,10 @@ export const addVisibleDraftEvent = ({
2829
return events;
2930
}
3031

32+
if (isAllDay) {
33+
return positionAllDayDraftEvent({ draft, events }).events;
34+
}
35+
3136
const draftEvent = assembleGridEvent(draft);
3237
const existingIndex = events.findIndex((event) => event._id === draft._id);
3338

0 commit comments

Comments
 (0)