Skip to content

Commit 69c7829

Browse files
authored
fix(web): all-day clicks (#1909)
* fix: allday click * refactor: simplify * feat: simplify again * fix(test): FloatingEventForm test
1 parent 30a0895 commit 69c7829

9 files changed

Lines changed: 292 additions & 91 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import {
2+
cleanup,
3+
fireEvent,
4+
render,
5+
screen,
6+
waitFor,
7+
} from "@testing-library/react";
8+
import { useEffect } from "react";
9+
import { Provider } from "react-redux";
10+
import { type Schema_Event } from "@core/types/event.types";
11+
import { createStoreWithEvents } from "@web/__tests__/utils/state/store.test.util";
12+
import { draftSlice } from "@web/ducks/events/slices/draft.slice";
13+
import { useAllDayDraftCreation } from "./useAllDayDraftCreation";
14+
import { afterEach, describe, expect, it, mock } from "bun:test";
15+
16+
mock.module("@web/auth/compass/session/session.util", () => ({
17+
getUserId: mock().mockResolvedValue("user"),
18+
}));
19+
20+
const existingDraft: Schema_Event = {
21+
_id: "existing-draft",
22+
endDate: "2026-05-21",
23+
isAllDay: true,
24+
isSomeday: false,
25+
startDate: "2026-05-20",
26+
title: "Existing draft",
27+
user: "user",
28+
};
29+
30+
const renderHarness = ({
31+
draft = null,
32+
onCreateDraft = mock(),
33+
onParentMouseDown = mock(),
34+
}: {
35+
draft?: Schema_Event | null;
36+
onCreateDraft?: (event: Schema_Event) => void;
37+
onParentMouseDown?: () => void;
38+
} = {}) => {
39+
const store = createStoreWithEvents([]);
40+
41+
if (draft) {
42+
store.dispatch(draftSlice.actions.startGridClick(draft));
43+
}
44+
45+
const Harness = () => {
46+
const onMouseDown = useAllDayDraftCreation({
47+
getStartDate: () => "2026-05-20",
48+
onCreateDraft,
49+
});
50+
51+
useEffect(() => {
52+
document.addEventListener("mousedown", onParentMouseDown);
53+
return () => document.removeEventListener("mousedown", onParentMouseDown);
54+
}, []);
55+
56+
return (
57+
<button onMouseDown={onMouseDown} type="button">
58+
Empty all-day space
59+
</button>
60+
);
61+
};
62+
63+
render(
64+
<Provider store={store}>
65+
<Harness />
66+
</Provider>,
67+
);
68+
69+
return { onCreateDraft, onParentMouseDown, store };
70+
};
71+
72+
afterEach(cleanup);
73+
74+
describe("useAllDayDraftCreation", () => {
75+
it("creates a one-day all-day draft and stops the opening press", async () => {
76+
const { onCreateDraft, onParentMouseDown } = renderHarness();
77+
78+
const wasNotCancelled = fireEvent.mouseDown(
79+
screen.getByRole("button", { name: "Empty all-day space" }),
80+
{ button: 0 },
81+
);
82+
83+
expect(wasNotCancelled).toBe(false);
84+
expect(onParentMouseDown).not.toHaveBeenCalled();
85+
await waitFor(() => expect(onCreateDraft).toHaveBeenCalledTimes(1));
86+
expect(onCreateDraft).toHaveBeenCalledWith(
87+
expect.objectContaining({
88+
endDate: "2026-05-21",
89+
isAllDay: true,
90+
startDate: "2026-05-20",
91+
}),
92+
);
93+
});
94+
95+
it("ignores right-click presses", () => {
96+
const { onCreateDraft, onParentMouseDown } = renderHarness();
97+
98+
fireEvent.mouseDown(
99+
screen.getByRole("button", { name: "Empty all-day space" }),
100+
{ button: 2 },
101+
);
102+
103+
expect(onCreateDraft).not.toHaveBeenCalled();
104+
expect(onParentMouseDown).toHaveBeenCalledTimes(1);
105+
});
106+
107+
it("dismisses an existing draft without creating a replacement", async () => {
108+
const { onCreateDraft, onParentMouseDown, store } = renderHarness({
109+
draft: existingDraft,
110+
});
111+
112+
fireEvent.mouseDown(
113+
screen.getByRole("button", { name: "Empty all-day space" }),
114+
{ button: 0 },
115+
);
116+
117+
await waitFor(() => expect(store.getState().events.draft.event).toBeNull());
118+
expect(onCreateDraft).not.toHaveBeenCalled();
119+
expect(onParentMouseDown).not.toHaveBeenCalled();
120+
});
121+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { type MouseEvent as ReactMouseEvent } from "react";
2+
import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants";
3+
import { Categories_Event, type Schema_Event } from "@core/types/event.types";
4+
import dayjs from "@core/util/date/dayjs";
5+
import { assembleDefaultEvent } from "@web/common/utils/event/event.util";
6+
import { isRightClick } from "@web/common/utils/mouse/mouse.util";
7+
import { selectIsDrafting } from "@web/ducks/events/selectors/draft.selectors";
8+
import { draftSlice } from "@web/ducks/events/slices/draft.slice";
9+
import { useAppDispatch, useAppSelector } from "@web/store/store.hooks";
10+
11+
interface UseAllDayDraftCreationOptions {
12+
getStartDate: (clientX: number, clientY: number) => string;
13+
onCreateDraft: (event: Schema_Event) => void;
14+
}
15+
16+
export const useAllDayDraftCreation = ({
17+
getStartDate,
18+
onCreateDraft,
19+
}: UseAllDayDraftCreationOptions) => {
20+
const dispatch = useAppDispatch();
21+
const isDrafting = useAppSelector(selectIsDrafting);
22+
23+
return (event: ReactMouseEvent<HTMLElement>) => {
24+
if (isRightClick(event)) {
25+
return;
26+
}
27+
28+
event.preventDefault();
29+
event.stopPropagation();
30+
31+
if (isDrafting) {
32+
dispatch(draftSlice.actions.discard(undefined));
33+
return;
34+
}
35+
36+
const startDate = getStartDate(event.clientX, event.clientY);
37+
const endDate = dayjs(startDate)
38+
.add(1, "day")
39+
.format(YEAR_MONTH_DAY_FORMAT);
40+
41+
void assembleDefaultEvent(Categories_Event.ALLDAY, startDate, endDate).then(
42+
onCreateDraft,
43+
);
44+
};
45+
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
2+
import { Provider } from "react-redux";
3+
import { Categories_Event, type Schema_Event } from "@core/types/event.types";
4+
import { createStoreWithEvents } from "@web/__tests__/utils/state/store.test.util";
5+
import { FloatingEventForm } from "@web/components/FloatingEventForm/FloatingEventForm";
6+
import { draftSlice } from "@web/ducks/events/slices/draft.slice";
7+
import { useEventForm } from "@web/views/Forms/hooks/useEventForm";
8+
import { afterEach, describe, expect, it } from "bun:test";
9+
import "@testing-library/jest-dom";
10+
11+
const draft: Schema_Event = {
12+
endDate: "2026-05-21",
13+
isAllDay: true,
14+
isSomeday: false,
15+
startDate: "2026-05-20",
16+
title: "",
17+
user: "user",
18+
};
19+
20+
afterEach(cleanup);
21+
22+
describe("FloatingEventForm", () => {
23+
it("focuses the title when the form opens", async () => {
24+
const store = createStoreWithEvents([]);
25+
store.dispatch(draftSlice.actions.startGridClick(draft));
26+
store.dispatch(draftSlice.actions.setFormOpen(true));
27+
28+
const Harness = () => {
29+
const form = useEventForm(Categories_Event.ALLDAY, true, () => undefined);
30+
31+
return (
32+
<>
33+
<button ref={form.refs.setReference} type="button">
34+
Draft event
35+
</button>
36+
<FloatingEventForm form={form} />
37+
</>
38+
);
39+
};
40+
41+
render(
42+
<Provider store={store}>
43+
<Harness />
44+
</Provider>,
45+
);
46+
47+
await waitFor(() =>
48+
expect(screen.getByPlaceholderText("Title")).toHaveFocus(),
49+
);
50+
});
51+
});

packages/web/src/components/FloatingEventForm/FloatingEventForm.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ export function FloatingEventForm({
6767
// close-on-blur here would close the form while focus is still
6868
// moving within a child menu's separate floating tree.
6969
closeOnFocusOut={false}
70-
order={["reference"]}
7170
>
7271
<div
7372
{...form.getFloatingProps()}

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {
2-
act,
32
cleanup,
43
fireEvent,
54
render,
@@ -8,6 +7,7 @@ import {
87
within,
98
} from "@testing-library/react";
109
import userEvent from "@testing-library/user-event";
10+
import { act } from "react";
1111
import { Provider } from "react-redux";
1212
import { type Schema_Event } from "@core/types/event.types";
1313
import dayjs from "@core/util/date/dayjs";
@@ -93,7 +93,11 @@ mock.module("@web/components/FloatingEventForm/FloatingEventForm", () => ({
9393
FloatingEventForm: ({ form }: { form: EventFormProps }) => {
9494
latestEventForm = form;
9595

96-
return getIsFormOpen() ? <dialog aria-label="Event form" open /> : null;
96+
return getIsFormOpen() ? (
97+
<dialog aria-label="Event form" open>
98+
<input aria-label="Event Title" autoFocus />
99+
</dialog>
100+
) : null;
97101
},
98102
}));
99103

@@ -509,6 +513,18 @@ describe("DayCalendarGrid", () => {
509513
expect(screen.getByRole("dialog", { name: "Event form" })).toBeVisible();
510514
});
511515

516+
await act(async () => {
517+
await new Promise((resolve) => setTimeout(resolve, 0));
518+
});
519+
520+
expect(
521+
screen.getByRole("button", {
522+
name: /all-day event: untitled event/i,
523+
}),
524+
).toBeVisible();
525+
expect(screen.getByRole("dialog", { name: "Event form" })).toBeVisible();
526+
expect(screen.getByRole("textbox", { name: "Event Title" })).toHaveFocus();
527+
512528
await user.pointer({ keys: "[/MouseLeft]" });
513529
});
514530

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

Lines changed: 15 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
import { type OpenChangeReason, type VirtualElement } from "@floating-ui/react";
2-
import {
3-
type MouseEvent as ReactMouseEvent,
4-
useCallback,
5-
useEffect,
6-
useMemo,
7-
useRef,
8-
} from "react";
2+
import { useCallback, useEffect, useMemo, useRef } from "react";
93
import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants";
10-
import { Categories_Event } from "@core/types/event.types";
4+
import { Categories_Event, type Schema_Event } from "@core/types/event.types";
115
import dayjs from "@core/util/date/dayjs";
126
import { CALENDAR_TIMED_VISIBLE_HOURS } from "@web/common/calendar-grid/calendarGrid.constants";
137
import { CalendarGrid } from "@web/common/calendar-grid/components/CalendarGrid";
8+
import { useAllDayDraftCreation } from "@web/common/calendar-grid/hooks/useAllDayDraftCreation";
149
import { useCalendarDateCalcs } from "@web/common/calendar-grid/hooks/useCalendarDateCalcs";
1510
import { useCalendarGridLayout } from "@web/common/calendar-grid/hooks/useCalendarGridLayout";
1611
import { type Schema_GridEvent } from "@web/common/types/web.event.types";
@@ -20,14 +15,11 @@ import {
2015
} from "@web/common/utils/dom/event-emitter.util";
2116
import {
2217
addId,
23-
assembleDefaultEvent,
2418
assembleGridEvent,
25-
type EventWithDates,
2619
getCalendarEventElementFromGrid,
2720
hasEventDates,
2821
} from "@web/common/utils/event/event.util";
2922
import { getCurrentMinute } from "@web/common/utils/grid/grid.util";
30-
import { isRightClick } from "@web/common/utils/mouse/mouse.util";
3123
import { FloatingEventForm } from "@web/components/FloatingEventForm/FloatingEventForm";
3224
import {
3325
selectDraft,
@@ -203,41 +195,19 @@ export function DayCalendarGrid() {
203195
onOpenEvent: openEventFormForEvent,
204196
});
205197

206-
const onAllDayMouseDown = useCallback(
207-
async (event: ReactMouseEvent<HTMLElement>) => {
208-
if (isRightClick(event)) {
209-
return;
210-
}
211-
212-
if (draft) {
213-
dispatch(draftSlice.actions.discard(undefined));
214-
return;
215-
}
216-
217-
const selectedDate =
218-
visibleDates[dateCalcs.getVisibleDateIndexByX(event.clientX)]?.date ??
219-
dateInView;
220-
const startDate = selectedDate.format(YEAR_MONTH_DAY_FORMAT);
221-
const endDate = selectedDate.add(1, "day").format(YEAR_MONTH_DAY_FORMAT);
222-
const draftEvent = await assembleDefaultEvent(
223-
Categories_Event.ALLDAY,
224-
startDate,
225-
endDate,
226-
);
198+
const getAllDayDraftStartDate = (clientX: number) =>
199+
dateCalcs.getDateStrByXY(clientX, 0, YEAR_MONTH_DAY_FORMAT);
200+
const openAllDayDraft = (event: Schema_Event) => {
201+
if (!hasEventDates(event)) {
202+
return;
203+
}
227204

228-
openEventFormForEvent(
229-
addId(assembleGridEvent(draftEvent as EventWithDates)),
230-
);
231-
},
232-
[
233-
dateCalcs,
234-
dateInView,
235-
dispatch,
236-
draft,
237-
openEventFormForEvent,
238-
visibleDates,
239-
],
240-
);
205+
openEventFormForEvent(addId(assembleGridEvent(event)));
206+
};
207+
const onAllDayMouseDown = useAllDayDraftCreation({
208+
getStartDate: getAllDayDraftStartDate,
209+
onCreateDraft: openAllDayDraft,
210+
});
241211

242212
const { startTimedDraftCreation } = useDayTimedDraftCreation({
243213
dateCalcs,

packages/web/src/views/Forms/hooks/useEventForm.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
offset,
77
type Placement,
88
shift,
9-
type UseDismissProps,
109
type UseFloatingOptions,
1110
useDismiss,
1211
useFloating,
@@ -28,11 +27,6 @@ const fallbackPlacements: Placement[] = [
2827
"left-start",
2928
];
3029

31-
export interface UseEventFormOptions {
32-
/** Options forwarded to floating-ui's `useDismiss`. */
33-
dismiss?: UseDismissProps;
34-
}
35-
3630
export const useEventForm = (
3731
category: Categories_Event,
3832
isOpen: boolean,
@@ -41,7 +35,6 @@ export const useEventForm = (
4135
event: Event,
4236
reason?: OpenChangeReason,
4337
) => void,
44-
options?: UseEventFormOptions,
4538
) => {
4639
let positioning: Partial<UseFloatingOptions>;
4740
const isSomeday =
@@ -111,7 +104,6 @@ export const useEventForm = (
111104
const dismiss = useDismiss(context, {
112105
capture: { outsidePress: false },
113106
outsidePressEvent: "mousedown",
114-
...options?.dismiss,
115107
});
116108
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
117109

0 commit comments

Comments
 (0)