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
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import dayjs from "@core/util/date/dayjs";
import { afterAll, describe, expect, it, mock } from "bun:test";
import {
afterAll,
beforeAll,
describe,
expect,
it,
mock,
setSystemTime,
} from "bun:test";

mock.module("@web/components/AbsoluteOverflowLoader", () => ({
AbsoluteOverflowLoader: () => (
Expand Down Expand Up @@ -37,6 +45,10 @@ mock.module("./SomedayEvents/SomedayEvents", () => ({
const { SomedayEventSections } =
require("./SomedayEventSections") as typeof import("./SomedayEventSections");

beforeAll(() => {
setSystemTime(new Date("2026-05-20T12:00:00.000Z"));
});

describe("SomedayEventSections", () => {
it("keeps the planner sidebar stable while someday events refresh", () => {
render(
Expand Down Expand Up @@ -71,5 +83,6 @@ describe("SomedayEventSections", () => {
});

afterAll(() => {
setSystemTime();
mock.restore();
});
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,30 @@ describe("useRecurrence hook", () => {
expect(result.current.freq).toBe(Frequency.MONTHLY);
expect(result.current.interval).toBe(2);
});

it("does not rewrite an unchanged recurring rule when the setter changes", () => {
const event = {
...baseEvent(),
startDate: "2026-05-31T10:00:00.000Z",
endDate: "2026-05-31T11:00:00.000Z",
recurrence: {
rule: ["RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU;COUNT=4"],
},
};
const setEvent = mock();
const nextSetEvent = mock();

const { rerender } = renderHook(
({ setEventProp }) => useRecurrence(event, { setEvent: setEventProp }),
{
initialProps: { setEventProp: setEvent },
},
);

expect(setEvent).not.toHaveBeenCalled();

rerender({ setEventProp: nextSetEvent });

expect(nextSetEvent).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ObjectId } from "bson";
import fastDeepEqual from "fast-deep-equal/react";
import {
type Dispatch,
type SetStateAction,
Expand Down Expand Up @@ -67,6 +68,7 @@ export const useRecurrence = (
const endDate = _endDate ?? dayjs().add(1, "hour").toRFC3339OffsetString();
const _startDate = parseCompassEventDate(startDate);
const hasRecurrence = (event?.recurrence?.rule?.length ?? 0) > 0;
const currentRule = event?.recurrence?.rule;

const { options } = useMemo(() => {
if (!hasRecurrence) {
Expand Down Expand Up @@ -169,15 +171,22 @@ export const useRecurrence = (
useEffect(() => {
if (!hasRecurrence) return;

const nextRule = JSON.parse(rule);
if (fastDeepEqual(currentRule, nextRule)) return;

setEvent((gridEvent): Schema_Event | null => {
if (!gridEvent) return gridEvent;

if (fastDeepEqual(gridEvent.recurrence?.rule, nextRule)) {
return gridEvent;
}

return {
...gridEvent,
recurrence: { ...(gridEvent.recurrence ?? {}), rule: JSON.parse(rule) },
recurrence: { ...(gridEvent.recurrence ?? {}), rule: nextRule },
};
});
}, [rule, hasRecurrence, setEvent]);
}, [currentRule, rule, hasRecurrence, setEvent]);

return {
hasRecurrence,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { configureStore } from "@reduxjs/toolkit";
import { act, renderHook } from "@testing-library/react";
import { ObjectId } from "bson";
import { type PropsWithChildren } from "react";
import { Provider } from "react-redux";
import { Origin, Priorities } from "@core/constants/core.constants";
import { RecurringEventUpdateScope } from "@core/types/event.types";
import {
RecurringEventUpdateScope,
type Schema_Event,
} from "@core/types/event.types";
import { createInitialState } from "@web/__tests__/utils/state/store.test.util";
import { type Schema_GridEvent } from "@web/common/types/web.event.types";
import { reducers } from "@web/store/reducers";
import { useDraftConfirmation } from "./useDraftConfirmation";
import { describe, expect, it, mock } from "bun:test";

Expand Down Expand Up @@ -32,18 +40,43 @@ const createDraft = (

const renderDraftConfirmation = ({
draft = createDraft(),
events = [],
isInstance = false,
isRecurrence = false,
isSomeday = false,
}: {
draft?: Schema_GridEvent;
events?: Schema_Event[];
isInstance?: boolean;
isRecurrence?: boolean;
isSomeday?: boolean;
} = {}) => {
const discard = mock();
const deleteEvent = mock();
const submit = mock();
const preloadedState = createInitialState();
preloadedState.events.entities!.value = events.reduce<
Record<string, Schema_Event>
>((entities, event) => {
if (event._id) {
entities[event._id] = event;
}

return entities;
}, {});
const store = configureStore({
reducer: reducers,
preloadedState,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
immutableCheck: false,
serializableCheck: false,
thunk: false,
}),
});
const wrapper = ({ children }: PropsWithChildren) => (
<Provider store={store}>{children}</Provider>
);

const context = {
actions: {
Expand All @@ -59,7 +92,9 @@ const renderDraftConfirmation = ({
},
} as unknown as Parameters<typeof useDraftConfirmation>[0];

const { result } = renderHook(() => useDraftConfirmation(context));
const { result } = renderHook(() => useDraftConfirmation(context), {
wrapper,
});

return { deleteEvent, discard, result, submit };
};
Expand Down Expand Up @@ -88,14 +123,23 @@ describe("useDraftConfirmation", () => {
expect(discard).toHaveBeenCalledTimes(1);
});

it("opens the update scope dialog for existing multi-occurrence recurring drafts", async () => {
const draft = createDraft({
it("opens the update scope dialog for existing multi-occurrence recurring instances", async () => {
const baseEventId = new ObjectId().toString();
const baseEvent = createDraft({
_id: baseEventId,
recurrence: {
eventId: new ObjectId().toString(),
rule: ["FREQ=WEEKLY;COUNT=4"],
},
});
const { discard, result, submit } = renderDraftConfirmation({ draft });
const draft = createDraft({
recurrence: {
eventId: baseEventId,
},
});
const { discard, result, submit } = renderDraftConfirmation({
draft,
events: [baseEvent],
});

await act(async () => {
await result.current.onSubmit(draft);
Expand All @@ -107,14 +151,23 @@ describe("useDraftConfirmation", () => {
expect(discard).not.toHaveBeenCalled();
});

it("submits a single-occurrence recurring draft without opening the update scope dialog", async () => {
const draft = createDraft({
it("submits a single-occurrence recurring instance without opening the update scope dialog", async () => {
const baseEventId = new ObjectId().toString();
const baseEvent = createDraft({
_id: baseEventId,
recurrence: {
eventId: new ObjectId().toString(),
rule: ["RRULE:FREQ=WEEKLY;COUNT=1"],
},
});
const { discard, result, submit } = renderDraftConfirmation({ draft });
const draft = createDraft({
recurrence: {
eventId: baseEventId,
},
});
const { discard, result, submit } = renderDraftConfirmation({
draft,
events: [baseEvent],
});

await act(async () => {
await result.current.onSubmit(draft);
Expand All @@ -129,4 +182,34 @@ describe("useDraftConfirmation", () => {
);
expect(discard).toHaveBeenCalledTimes(1);
});

it("opens the update scope dialog before deleting recurring timed drafts", async () => {
const draft = createDraft({
recurrence: {
rule: ["RRULE:FREQ=WEEKLY;COUNT=4"],
},
});
const { deleteEvent, discard, result } = renderDraftConfirmation({
draft,
isRecurrence: true,
});

await act(async () => {
await result.current.onDelete();
});

expect(result.current.isRecurrenceUpdateScopeDialogOpen).toBe(true);
expect(result.current.finalDraft).toBeNull();
expect(deleteEvent).not.toHaveBeenCalled();
expect(discard).not.toHaveBeenCalled();

act(() => {
result.current.onUpdateScopeChange(RecurringEventUpdateScope.ALL_EVENTS);
});

expect(deleteEvent).toHaveBeenCalledWith(
RecurringEventUpdateScope.ALL_EVENTS,
);
expect(discard).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { useCallback, useState } from "react";
import { RecurringEventUpdateScope } from "@core/types/event.types";
import { CompassEventRRule } from "@core/util/event/compass.event.rrule";
import { type Schema_GridEvent } from "@web/common/types/web.event.types";
import { type Entities_Event } from "@web/ducks/events/event.types";
import { selectEventEntities } from "@web/ducks/events/selectors/event.selectors";
import { useAppSelector } from "@web/store/store.hooks";
import { type useDraftContext } from "@web/views/Week/components/Draft/context/useDraftContext";

const hasMultipleRecurrenceOccurrences = (event: Schema_GridEvent): boolean => {
const rule = event.recurrence?.rule;

const hasMultipleRecurrenceOccurrences = (
event: Schema_GridEvent,
rule: string[] | null | undefined,
): boolean => {
if (!Array.isArray(rule) || rule.length === 0) {
return true;
}
Expand All @@ -26,6 +30,23 @@ const hasMultipleRecurrenceOccurrences = (event: Schema_GridEvent): boolean => {
}
};

const getScopeDecisionRecurrenceRule = (
event: Schema_GridEvent,
eventEntities: Entities_Event,
): string[] | null | undefined => {
const rule = event.recurrence?.rule;
if (Array.isArray(rule) || rule === null) {
return rule;
}

const baseEventId = event.recurrence?.eventId;
if (!baseEventId) {
return undefined;
}

return eventEntities[baseEventId]?.recurrence?.rule;
};

export const useDraftConfirmation = ({
actions,
state,
Expand All @@ -34,6 +55,7 @@ export const useDraftConfirmation = ({
const { isInstance, isRecurrence } = actions;
const { draft } = state;
const isSomeday = actions.isSomeday();
const eventEntities = useAppSelector(selectEventEntities);

const [
isRecurrenceUpdateScopeDialogOpen,
Expand All @@ -59,7 +81,7 @@ export const useDraftConfirmation = ({

const onSubmit = useCallback(
async (_draft: Schema_GridEvent) => {
const rule = _draft.recurrence?.rule;
const rule = getScopeDecisionRecurrenceRule(_draft, eventEntities);
const draftIsInstance = ObjectId.isValid(
_draft.recurrence?.eventId ?? "",
);
Expand All @@ -69,7 +91,10 @@ export const useDraftConfirmation = ({
isExistingDraft && (isRecurrence() || draftIsRecurring);
const instanceEvent = isInstance() || draftIsInstance;
const toStandAlone = instanceEvent && rule === null;
const hasMultipleOccurrences = hasMultipleRecurrenceOccurrences(_draft);
const hasMultipleOccurrences = hasMultipleRecurrenceOccurrences(
_draft,
rule,
);
const isSingleOccurrenceInstance =
isRecurringEvent && instanceEvent && !hasMultipleOccurrences;
const shouldAskForUpdateScope =
Expand Down Expand Up @@ -97,7 +122,7 @@ export const useDraftConfirmation = ({
submit(_draft, applyTo);
discard();
},
[submit, isRecurrence, isInstance, discard],
[submit, isRecurrence, isInstance, discard, eventEntities],
);

const onDelete = useCallback(async () => {
Expand Down