Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions packages/web/src/components/ContextMenu/GridContextMenuWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { useQueryClient } from "@tanstack/react-query";
import type React from "react";
import { useState } from "react";
import { getCalendarEventIdFromElement } from "@web/common/utils/event/event.util";
import { type GridEventDraft } from "@web/events/event-draft.types";
import {
editGridEventDraft,
getGridDraftId,
gridEventDraftToGridEvent,
} from "@web/events/grid-event-draft.adapter";
import { findEventInCache } from "@web/events/queries/event.query.cache";
Expand Down Expand Up @@ -46,14 +48,23 @@ export const ContextMenuWrapper = ({
},
});

const getSelectedEvent = (eventId: string) => {
const selectedEvent = findEventInCache(queryClient, eventId);

if (!selectedEvent) {
throw new Error("Selected event not found");
const getDraftForEvent = (eventId: string): GridEventDraft | null => {
// The id comes from the DOM (`data-event-id`), which can legitimately
// disagree with the react-query cache: an in-progress grid draft renders
// with `draft.clientId`/its edited source id (never cached), so match it
// directly rather than looking it up.
if (gridDraft && getGridDraftId(gridDraft) === eventId) {
return gridDraft;
}

return selectedEvent;
// A cache miss is an ordinary outcome (unsaved drafts, non-cache-backed
// placeholderData during week navigation, optimistically removed events
// still in the DOM for a frame). Treat it as a no-op — like every other
// `findEventInCache` caller — instead of throwing and taking down the grid.
const selectedEvent = findEventInCache(queryClient, eventId);
if (!selectedEvent) return null;

return editGridEventDraft(selectedEvent);
};

const handleDiscard = () => {
Expand All @@ -73,7 +84,7 @@ export const ContextMenuWrapper = ({
if (hasClickedOnEvent) {
e.preventDefault();

const draft = editGridEventDraft(getSelectedEvent(eventId));
const draft = getDraftForEvent(eventId);
if (!draft) return;

refs.setReference(cursorReference(e.clientX, e.clientY));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,25 @@ describe("context menu layering", () => {
expectMenuFloatsAboveTheGrid();
});

// Right-clicking a card whose id isn't in the cache (unsaved drafts,
// week-transition placeholderData, optimistically removed events) used to
// throw an uncaught "Selected event not found" and take down the grid.
// Now the miss is a no-op: no throw, no menu.
it("ignores a right-click on an event that isn't in the cache", () => {
render(
<ContextMenuWrapper id={WRAPPER_ID}>
<div {...{ [DATA_EVENT_ELEMENT_ID]: "not-in-cache" }}>Orphan event</div>
</ContextMenuWrapper>,
{ queryClient: createCompassQueryClient() },
);

expect(() =>
fireEvent.contextMenu(screen.getByText("Orphan event")),
).not.toThrow();

expect(document.querySelector(".c-context-menu")).toBeNull();
});

// The day view builds its own menu around the same component. It used to
// lean on a z-index baked into the shared stylesheet, so removing that
// silently dropped it behind every card.
Expand Down