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
81 changes: 81 additions & 0 deletions packages/web/src/events/mutations/useEventMutations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,87 @@ describe("useEventMutations", () => {
});
context.pending.resolve();
});

test("runs replace onOptimisticApplied after the optimistic cache write", async () => {
const context = setup();
const original = event({
content: {
kind: "details",
title: "Original",
description: "",
color: "blue",
},
});
context.queryClient.setQueryData(calendarKey, normalized(original));

let colorAtCallback: unknown;
const onOptimisticApplied = mock(() => {
colorAtCallback = (
context.queryClient.getQueryData<NormalizedEventQueryData>(calendarKey)
?.entities[original.id].content as { color?: string }
).color;
});

act(() =>
context.hook.result.current.mutations.replace(
replacePayload(original.id, {
content: {
kind: "details",
title: "Original",
description: "",
location: "",
color: "coral",
},
}),
{ onOptimisticApplied },
),
);

await waitFor(() => {
expect(onOptimisticApplied).toHaveBeenCalledTimes(1);
});
expect(colorAtCallback).toBe("coral");

context.pending.resolve();
});

test("clears colorHex on optimistic replace when a slot color is written", async () => {
const context = setup();
const original = event({
content: {
kind: "details",
title: "Original",
description: "",
color: "blue",
colorHex: "#009688",
},
});
context.queryClient.setQueryData(calendarKey, normalized(original));

act(() =>
context.hook.result.current.mutations.replace(
replacePayload(original.id, {
content: {
kind: "details",
title: "Original",
description: "",
location: "",
color: "coral",
},
}),
),
);

await waitFor(() => {
const content =
context.queryClient.getQueryData<NormalizedEventQueryData>(calendarKey)
?.entities[original.id].content;
expect(content).toMatchObject({ kind: "details", color: "coral" });
expect(content).not.toHaveProperty("colorHex");
});

context.pending.resolve();
});
});

describe("undo history recording", () => {
Expand Down
12 changes: 11 additions & 1 deletion packages/web/src/events/mutations/useEventMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,13 @@ function mergeReplaceContent(
input: ReplaceEventInput["content"],
): Event["content"] {
if (existing.kind !== "details") return input;
// Slot writes (including null clear) supersede a provider custom hex on the
// optimistic card. Palette resolution prefers colorHex over color, so keeping
// the old hex would leave the prior fill until settle/refetch.
if (input.kind === "details" && input.color !== undefined) {
const { colorHex: _cleared, ...withoutHex } = existing;
return { ...withoutHex, ...input };
}
return { ...existing, ...input };
}

Expand Down Expand Up @@ -217,6 +224,7 @@ type ReplaceVariables = {
writeKey: EventId;
originalOverride?: Event;
opportunityId?: number;
callbacks?: EventMutationCallbacks;
};
type DeleteVariables = {
id: EventId;
Expand Down Expand Up @@ -732,8 +740,10 @@ export function useEventMutations(
source,
});
}
// callbacks rides in variables so onMutate can run onOptimisticApplied
// in the same task as the cache write (same pattern as create above).
replaceMutation.mutate(
{ ...payload, writeKey, opportunityId },
{ ...payload, writeKey, opportunityId, callbacks },
callbacks,
);
},
Expand Down
8 changes: 6 additions & 2 deletions packages/web/src/views/Forms/hooks/useSaveEventForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,12 @@ export function useSaveEventForm() {

if (parsed.mode === "edit") {
clearFieldErrors();
replace({ id: parsed.eventId, input: parsed.input });
closeEventForm();
// Same as create: keep the draft mounted until the optimistic replace
// exists so the grid never paints a frame with the pre-edit color.
replace(
{ id: parsed.eventId, input: parsed.input },
{ onOptimisticApplied: closeEventForm },
);
}
},
[
Expand Down
148 changes: 148 additions & 0 deletions packages/web/src/views/Forms/hooks/useSetEventColor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { type PropsWithChildren } from "react";
import { type Event } from "@core/types/event.contracts";
import { createMockEvent } from "@web/__tests__/utils/factories/event.factory";
import { editGridEventDraft } from "@web/events/grid-event-draft.adapter";
import { eventQueryKeys } from "@web/events/queries/event.query.keys";
import { type NormalizedEventQueryData } from "@web/events/queries/event.query.types";
import {
draftActions,
initialDraftState,
useDraftStore,
} from "@web/events/stores/draft.store";
import { useSetEventColor } from "./useSetEventColor";
import { beforeEach, describe, expect, it } from "bun:test";

const calendarKey = eventQueryKeys.week({
source: "local",
start: "2026-07-01T00:00:00.000Z",
end: "2026-07-08T00:00:00.000Z",
});

const normalized = (...events: Event[]): NormalizedEventQueryData => ({
ids: events.map(({ id }) => id),
entities: Object.fromEntries(events.map((item) => [item.id, item])),
});

function createWrapper(events: Event[]) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
queryClient.setQueryData(calendarKey, normalized(...events));

function Wrapper({ children }: PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

return { queryClient, Wrapper };
}

describe("useSetEventColor", () => {
beforeEach(() => {
draftActions.discard();
});

it("paints the new color on the draft before the optimistic replace lands", async () => {
const existing = createMockEvent({
content: {
kind: "details",
title: "All day",
description: "",
color: "blue",
},
schedule: {
kind: "allDay",
start: "2026-07-02" as never,
end: "2026-07-03" as never,
},
});
const { queryClient, Wrapper } = createWrapper([existing]);
const { result } = renderHook(() => useSetEventColor(existing.id), {
wrapper: Wrapper,
});

act(() => {
result.current("coral");
});

expect(useDraftStore.getState().gridDraft?.values.color).toBe("coral");

await waitFor(() => {
expect(
(
queryClient.getQueryData<NormalizedEventQueryData>(calendarKey)
?.entities[existing.id].content as { color?: string }
).color,
).toBe("coral");
});

await waitFor(() => {
expect(useDraftStore.getState()).toEqual(initialDraftState);
});
});

it("discards immediately when the color is unchanged", () => {
const existing = createMockEvent({
content: {
kind: "details",
title: "All day",
description: "",
color: "coral",
},
});
const draft = editGridEventDraft(existing);
if (!draft) throw new Error("expected edit draft");
draftActions.startGridDraft({ activity: "eventRightClick", draft });

const { Wrapper } = createWrapper([existing]);
const { result } = renderHook(() => useSetEventColor(existing.id), {
wrapper: Wrapper,
});

act(() => {
result.current("coral");
});

expect(useDraftStore.getState()).toEqual(initialDraftState);
});

it("updates a right-click draft that still held the old color", async () => {
const existing = createMockEvent({
content: {
kind: "details",
title: "Timed",
description: "",
color: "blue",
},
schedule: {
kind: "timed",
start: "2026-07-02T16:00:00.000Z" as never,
end: "2026-07-02T17:00:00.000Z" as never,
timeZone: "UTC" as never,
},
});
const draft = editGridEventDraft(existing);
if (!draft) throw new Error("expected edit draft");
expect(draft.values.color).toBe("blue");
draftActions.startGridDraft({ activity: "eventRightClick", draft });

const { Wrapper } = createWrapper([existing]);
const { result } = renderHook(() => useSetEventColor(existing.id), {
wrapper: Wrapper,
});

act(() => {
result.current("mint");
});

// Draft keeps the new color until onOptimisticApplied discards it.
expect(useDraftStore.getState().gridDraft?.values.color).toBe("mint");

await waitFor(() => {
expect(useDraftStore.getState()).toEqual(initialDraftState);
});
});
});
25 changes: 17 additions & 8 deletions packages/web/src/views/Forms/hooks/useSetEventColor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import { type EventColorSlot } from "@core/types/event-color.contracts";
import {
editGridEventDraft,
parseGridEventDraft,
patchGridDraftFields,
} from "@web/events/grid-event-draft.adapter";
import { useEventMutations } from "@web/events/mutations/useEventMutations";
import { useEventById } from "@web/events/queries/useEventById";
import { draftActions } from "@web/events/stores/draft.store";

/**
* Immediately replaces an event's color tag (or clears it with null), then
* discards any right-click / grid draft. Used by the event context menu.
* discards any right-click / grid draft once the optimistic cache write lands.
* Used by the event context menu.
*/
export function useSetEventColor(_id: string) {
const existingEvent = useEventById(_id);
Expand All @@ -32,14 +34,21 @@ export function useSetEventColor(_id: string) {
const draft = editGridEventDraft(existingEvent);
if (!draft || draft.kind !== "edit") return;

const parsed = parseGridEventDraft({
...draft,
values: { ...draft.values, color },
});
if (parsed.ok && parsed.mode === "edit") {
replace({ id: parsed.eventId, input: parsed.input });
const patchedDraft = patchGridDraftFields(draft, { color });
const parsed = parseGridEventDraft(patchedDraft);
if (!parsed.ok || parsed.mode !== "edit") {
draftActions.discard();
return;
}
draftActions.discard();

// Paint the new color on the draft card before replace's async onMutate
// finishes cancelQueries + optimistic cache write (Day may have no draft
// yet; Week's right-click draft still holds the old color).
draftActions.setGridDraft(patchedDraft);
replace(
{ id: parsed.eventId, input: parsed.input },
{ onOptimisticApplied: () => draftActions.discard() },
);
},
[existingEvent, replace],
);
Expand Down
Loading