Skip to content

Commit b03703c

Browse files
:sparkles feat(agenda-events): fix event form context menu visibility (#1393)
* :sparkles feat(agenda-events): improve z-index mgmt within event form add event context menu delete and duplicate functionalities * fix(context-menu): do not close form if delete is not confirmed * fix(context-menu): delete draft events on confirmation
1 parent ebc57ef commit b03703c

20 files changed

Lines changed: 496 additions & 87 deletions

File tree

packages/web/src/common/hooks/useMainGridSelection.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
getElementAtPoint,
2121
selectionStart$,
2222
} from "@web/common/utils/dom/event-emitter.util";
23-
import { SLOT_HEIGHT } from "../../views/Day/constants/day.constants";
23+
import { SLOT_HEIGHT } from "@web/views/Day/constants/day.constants";
2424

2525
const selection$ = combineLatest([
2626
pointerState$,

packages/web/src/common/utils/dom/event-emitter.util.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { EventEmitter2 } from "eventemitter2";
22
import { PointerEvent } from "react";
33
import { BehaviorSubject, Subject } from "rxjs";
44
import { StringV4Schema } from "@core/types/type.utils";
5+
import { isLeftClick } from "../mouse/mouse.util";
56

67
export interface KeyCombination {
78
event: KeyboardEvent;
@@ -86,17 +87,18 @@ export function getElementAtPoint({
8687
return element;
8788
}
8889

89-
function checkPointerDown(
90-
event: Pick<PointerEvent, "target" | "type" | "clientX" | "clientY">,
91-
): {
90+
function checkPointerDown(event: PointerEvent): {
9291
pointerdown: boolean;
9392
selectionStart: DomMovement["selectionStart"];
9493
} {
9594
const isPointerDownEvent = event.type === "pointerdown";
9695
const isPointerUpEvent = event.type === "pointerup";
9796
const { clientX, clientY } = event;
9897

99-
if (isPointerDownEvent) {
98+
// Only treat primary-button pointerdown as the start of a drag/selection.
99+
// This avoids incorrectly entering pointerdown state for right-clicks or
100+
// other non-primary button interactions (e.g. context menu).
101+
if (isPointerDownEvent && isLeftClick(event)) {
100102
pointerdown$.next(true);
101103
selectionStart$.next({ clientX, clientY });
102104
}
@@ -112,9 +114,7 @@ function checkPointerDown(
112114
return { pointerdown: isPointerDown, selectionStart };
113115
}
114116

115-
function processMovement(
116-
e: Pick<PointerEvent, "clientX" | "clientY" | "target" | "type">,
117-
) {
117+
function processMovement(e: PointerEvent) {
118118
const pointerdown = checkPointerDown(e);
119119
const elem = getElementAtPoint(e);
120120
const element = elem ?? (e.target instanceof Element ? e.target : null);

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
1-
import { useCallback } from "react";
1+
import { useCallback, useMemo } from "react";
22
import {
33
FloatingFocusManager,
44
FloatingPortal,
55
UseInteractionsReturn,
66
useFloating,
77
} from "@floating-ui/react";
8+
import { getEntity } from "@ngneat/elf-entities";
89
import { Schema_Event, WithCompassId } from "@core/types/event.types";
910
import { useGridMaxZIndex } from "@web/common/hooks/useGridMaxZIndex";
1011
import {
1112
CursorItem,
1213
useFloatingNodeIdAtCursor,
1314
useFloatingOpenAtCursor,
1415
} from "@web/common/hooks/useOpenAtCursor";
15-
import { setDraft } from "@web/store/events";
16+
import { eventsStore, setDraft } from "@web/store/events";
1617
import { useDraft } from "@web/views/Calendar/components/Draft/context/useDraft";
1718
import { EventForm } from "@web/views/Forms/EventForm/EventForm";
1819
import { useCloseEventForm } from "@web/views/Forms/hooks/useCloseEventForm";
20+
import { useDeleteEvent } from "@web/views/Forms/hooks/useDeleteEvent";
21+
import { useDuplicateEvent } from "@web/views/Forms/hooks/useDuplicateEvent";
1922
import { useSaveEventForm } from "@web/views/Forms/hooks/useSaveEventForm";
2023

2124
export function FloatingEventForm({
@@ -26,13 +29,20 @@ export function FloatingEventForm({
2629
interactions: UseInteractionsReturn;
2730
}) {
2831
const draft = useDraft();
32+
const _id = draft?._id;
2933
const nodeId = useFloatingNodeIdAtCursor();
3034
const floatingOpenAtCursor = useFloatingOpenAtCursor();
3135
const onSave = useSaveEventForm();
36+
const onDelete = useDeleteEvent(draft?._id as string);
37+
const onDuplicate = useDuplicateEvent(draft?._id as string);
3238
const onClose = useCloseEventForm();
3339
const maxZIndex = useGridMaxZIndex();
3440
const isOpenAtCursor = nodeId === CursorItem.EventForm;
3541
const open = floatingOpenAtCursor && isOpenAtCursor && !!draft;
42+
const existing = useMemo(
43+
() => !!_id && !!eventsStore.query(getEntity(_id)),
44+
[_id],
45+
);
3646

3747
const setEvent = useCallback(
3848
(
@@ -67,8 +77,11 @@ export function FloatingEventForm({
6777
>
6878
<EventForm
6979
event={draft}
80+
isDraft={true}
81+
isExistingEvent={existing}
7082
onClose={onClose}
71-
onDelete={() => {}}
83+
onDelete={onDelete}
84+
onDuplicate={onDuplicate}
7285
onSubmit={onSave}
7386
setEvent={setEvent}
7487
/>

packages/web/src/components/Tooltip/Tooltip.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "react";
88
import { FloatingPortal, useMergeRefs } from "@floating-ui/react";
99
import { ZIndex } from "@web/common/constants/web.constants";
10+
import { useGridMaxZIndex } from "@web/common/hooks/useGridMaxZIndex";
1011
import { TooltipOptions } from "./tooltip.types";
1112
import { TooltipContext, useTooltip, useTooltipContext } from "./useTooltip";
1213

@@ -63,6 +64,7 @@ export const TooltipContent = forwardRef<
6364
HTMLProps<HTMLDivElement>
6465
>(function TooltipContent({ children, style, ...props }, propRef) {
6566
const context = useTooltipContext();
67+
const maxZIndex = useGridMaxZIndex();
6668
const ref = useMergeRefs([context.refs.setFloating, propRef]);
6769

6870
return (
@@ -75,7 +77,7 @@ export const TooltipContent = forwardRef<
7577
position: context.strategy,
7678
top: context.y ?? 0,
7779
visibility: context.x == null ? "hidden" : "visible",
78-
zIndex: ZIndex.LAYER_3,
80+
zIndex: maxZIndex + ZIndex.LAYER_3,
7981
...style,
8082
}}
8183
{...context.getFloatingProps(props)}

packages/web/src/views/Calendar/components/Draft/grid/GridDraft.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import React, { FC, MouseEvent, useMemo } from "react";
1+
import { FC, MouseEvent } from "react";
22
import { FloatingFocusManager } from "@floating-ui/react";
33
import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants";
4-
import { Categories_Event } from "@core/types/event.types";
4+
import { Categories_Event, Schema_Event } from "@core/types/event.types";
55
import { PartialMouseEvent } from "@web/common/types/util.types";
66
import { Schema_GridEvent } from "@web/common/types/web.event.types";
77
import { getEventDragOffset } from "@web/common/utils/event/event.util";
@@ -105,11 +105,13 @@ export const GridDraft: FC<Props> = ({ measurements, weekProps }) => {
105105
{...getFloatingProps()}
106106
>
107107
<EventForm
108-
event={draft}
108+
event={draft as Schema_Event}
109109
onClose={discard}
110110
onConvert={onConvert}
111111
onDelete={onDelete}
112112
onDuplicate={duplicateEvent}
113+
isDraft={!draft._id}
114+
isExistingEvent={!!draft._id}
113115
onSubmit={onSubmit}
114116
setEvent={setDraft}
115117
/>

packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,13 @@ export const DraggableTimedAgendaEvent = memo(
6565
const isBeingResized = resizeId === event._id;
6666
const enableInteractions = !resizing && !selecting && !eventFormOpen;
6767
const mainGridHeight = mainGrid?.offsetHeight ?? 0;
68+
const { _id, endDate, isAllDay } = event;
6869

6970
const { onResize, onResizeStart, onResizeStop } = useEventResizeActions(
7071
event as WithCompassId<Schema_Event>,
7172
);
7273

73-
if (!event.startDate || !event.endDate || event.isAllDay) return null;
74+
if (!_id || !event.startDate || !endDate || isAllDay) return null;
7475

7576
return (
7677
<Draggable
@@ -82,7 +83,7 @@ export const DraggableTimedAgendaEvent = memo(
8283
: undefined,
8384
})}
8485
dndProps={{
85-
id: event._id!,
86+
id: _id,
8687
data: {
8788
event: event,
8889
type: Categories_Event.TIMED,

packages/web/src/views/Day/components/ContextMenu/EventContextMenu.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ describe("EventContextMenu", () => {
118118
});
119119

120120
it("should work with multiple events", async () => {
121+
jest.spyOn(window, "confirm").mockImplementation(() => true);
122+
121123
const mockEvents = [
122124
{ ...baseEvent, _id: "event-1", title: "First Event" },
123125
{ ...baseEvent, _id: "event-2", title: "Second Event" },

packages/web/src/views/Day/components/ContextMenu/EventContextMenu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function EventContextMenu({
4444
zIndex: maxZIndex + 3,
4545
}}
4646
>
47-
<EventContextMenuItems />
47+
<EventContextMenuItems id={activeEvent._id} />
4848
</ul>
4949
</FloatingPortal>
5050
);

packages/web/src/views/Day/components/ContextMenu/EventContextMenuItems.test.tsx

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@ import {
1010
} from "@core/types/event.types";
1111
import { closeFloatingAtCursor } from "@web/common/hooks/useOpenAtCursor";
1212
import { deleteEventSlice } from "@web/ducks/events/slices/event.slice";
13-
import { activeEvent$ } from "@web/store/events";
13+
import { activeEvent$, eventsStore } from "@web/store/events";
1414
import { EventContextMenuItems } from "@web/views/Day/components/ContextMenu/EventContextMenuItems";
1515

1616
jest.mock("@web/store/events", () => {
1717
const { BehaviorSubject } = require("rxjs");
1818
return {
1919
activeEvent$: new BehaviorSubject(null),
20+
eventsStore: {
21+
query: jest.fn(),
22+
},
23+
getDraft: jest.fn(),
24+
resetDraft: jest.fn(),
2025
};
2126
});
2227
jest.mock("@web/common/hooks/useOpenAtCursor");
@@ -43,14 +48,16 @@ describe("EventContextMenuItems", () => {
4348
beforeEach(() => {
4449
jest.clearAllMocks();
4550
(closeFloatingAtCursor as jest.Mock).mockImplementation(mockClose);
51+
global.confirm = jest.fn(() => true);
52+
(eventsStore.query as jest.Mock).mockReturnValue(mockEvent);
4653
});
4754

4855
const renderWithProvider = (event: Schema_Event) => {
4956
const store = createMockStore();
5057
(activeEvent$ as BehaviorSubject<Schema_Event | null>).next(event);
5158
return render(
5259
<Provider store={store}>
53-
<EventContextMenuItems />
60+
<EventContextMenuItems id={event._id!} />
5461
</Provider>,
5562
);
5663
};
@@ -70,7 +77,7 @@ describe("EventContextMenuItems", () => {
7077

7178
render(
7279
<Provider store={store}>
73-
<EventContextMenuItems />
80+
<EventContextMenuItems id={mockEvent._id!} />
7481
</Provider>,
7582
);
7683

@@ -94,7 +101,7 @@ describe("EventContextMenuItems", () => {
94101

95102
render(
96103
<Provider store={store}>
97-
<EventContextMenuItems />
104+
<EventContextMenuItems id={mockEvent._id!} />
98105
</Provider>,
99106
);
100107

@@ -118,7 +125,7 @@ describe("EventContextMenuItems", () => {
118125

119126
render(
120127
<Provider store={store}>
121-
<EventContextMenuItems />
128+
<EventContextMenuItems id={mockEvent._id!} />
122129
</Provider>,
123130
);
124131

@@ -132,25 +139,4 @@ describe("EventContextMenuItems", () => {
132139
});
133140
expect(mockClose).toHaveBeenCalled();
134141
});
135-
136-
it("should not delete if event has no _id", async () => {
137-
const user = userEvent.setup();
138-
const store = createMockStore();
139-
const dispatchSpy = jest.spyOn(store, "dispatch");
140-
141-
const eventWithoutId = { ...mockEvent, _id: undefined };
142-
143-
(activeEvent$ as unknown as BehaviorSubject<any>).next(eventWithoutId);
144-
145-
render(
146-
<Provider store={store}>
147-
<EventContextMenuItems />
148-
</Provider>,
149-
);
150-
151-
const deleteButton = screen.getByText("Delete Event");
152-
await user.click(deleteButton);
153-
154-
expect(dispatchSpy).not.toHaveBeenCalled();
155-
});
156142
});

packages/web/src/views/Day/components/ContextMenu/EventContextMenuItems.tsx

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,23 @@
11
import React, { useCallback } from "react";
2-
import { useObservable } from "@ngneat/use-observable";
32
import { TrashIcon } from "@phosphor-icons/react";
4-
import { RecurringEventUpdateScope } from "@core/types/event.types";
5-
import { closeFloatingAtCursor } from "@web/common/hooks/useOpenAtCursor";
6-
import { deleteEventSlice } from "@web/ducks/events/slices/event.slice";
7-
import { activeEvent$ } from "@web/store/events";
8-
import { useAppDispatch } from "@web/store/store.hooks";
3+
import { useDeleteEvent } from "@web/views/Forms/hooks/useDeleteEvent";
94

10-
export function EventContextMenuItems() {
11-
const [activeEvent] = useObservable(activeEvent$);
12-
const dispatch = useAppDispatch();
13-
14-
const handleDelete = useCallback(() => {
15-
if (!activeEvent?._id) return;
16-
17-
// Dispatch delete action
18-
dispatch(
19-
deleteEventSlice.actions.request({
20-
_id: activeEvent?._id,
21-
applyTo: RecurringEventUpdateScope.THIS_EVENT,
22-
}),
23-
);
24-
25-
closeFloatingAtCursor();
26-
}, [dispatch, activeEvent?._id]);
5+
export function EventContextMenuItems({ id }: { id: string }) {
6+
const deleteEvent = useDeleteEvent(id);
277

288
const handleKeyDown = useCallback(
299
(e: React.KeyboardEvent) => {
3010
if (e.key === "Enter" || e.key === " ") {
3111
e.preventDefault();
32-
handleDelete();
12+
deleteEvent();
3313
}
3414
},
35-
[handleDelete],
15+
[deleteEvent],
3616
);
3717

3818
return (
3919
<li
40-
onClick={handleDelete}
20+
onClick={() => deleteEvent()}
4121
onKeyDown={handleKeyDown}
4222
role="menuitem"
4323
tabIndex={0}

0 commit comments

Comments
 (0)