From f1ff7775c09383fb09932ac589fd1396d1bd9e10 Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Mon, 4 May 2026 13:12:43 -0500 Subject: [PATCH] refactor(web): extract draft action helpers --- .../hooks/actions/draft.movement.test.ts | 61 +++++++++++++ .../Draft/hooks/actions/draft.movement.ts | 58 ++++++++++++ .../actions/draft.submit-decision.test.ts | 59 +++++++++++++ .../hooks/actions/draft.submit-decision.ts | 25 ++++++ .../Draft/hooks/actions/submit.parser.test.ts | 2 + .../Draft/hooks/actions/useDraftActions.ts | 88 +++++++------------ 6 files changed, 235 insertions(+), 58 deletions(-) create mode 100644 packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.test.ts create mode 100644 packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.ts create mode 100644 packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.test.ts create mode 100644 packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.ts diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.test.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.test.ts new file mode 100644 index 000000000..a21522fef --- /dev/null +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.test.ts @@ -0,0 +1,61 @@ +import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; +import dayjs from "@core/util/date/dayjs"; +import { + getDraggedEventDateRange, + getIsValidResizeMovement, +} from "./draft.movement"; +import { describe, expect, it } from "bun:test"; + +describe("draft movement helpers", () => { + it("keeps timed drag ranges within the same day when the end would overflow", () => { + const start = dayjs("2024-01-15T23:45:00.000"); + + const result = getDraggedEventDateRange({ + eventStart: start, + durationMin: 60, + isAllDay: false, + }); + + expect(dayjs(result.startDate).format("HH:mm")).toBe("23:00"); + expect(dayjs(result.endDate).format("HH:mm")).toBe("00:00"); + }); + + it("formats all-day drag ranges as date-only values", () => { + const start = dayjs("2024-01-15T09:00:00.000Z"); + + const result = getDraggedEventDateRange({ + eventStart: start, + durationMin: 1440, + isAllDay: true, + }); + + expect(result.startDate).toBe(start.format(YEAR_MONTH_DAY_FORMAT)); + expect(result.endDate).toBe( + start.add(1440, "minutes").format(YEAR_MONTH_DAY_FORMAT), + ); + }); + + it("rejects resize movement that changes a timed event to another day", () => { + expect( + getIsValidResizeMovement({ + currTime: dayjs("2024-01-16T10:00:00.000Z"), + draftStartDate: "2024-01-15T09:00:00.000Z", + currentValue: "2024-01-15T10:00:00.000Z", + dateBeingChanged: "endDate", + isAllDay: false, + }), + ).toBe(false); + }); + + it("accepts all-day resize movement across dates", () => { + expect( + getIsValidResizeMovement({ + currTime: dayjs("2024-01-16T00:00:00.000Z"), + draftStartDate: "2024-01-15", + currentValue: "2024-01-15", + dateBeingChanged: "endDate", + isAllDay: true, + }), + ).toBe(true); + }); +}); diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.ts new file mode 100644 index 000000000..0391daca4 --- /dev/null +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.movement.ts @@ -0,0 +1,58 @@ +import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; +import dayjs, { type Dayjs } from "@core/util/date/dayjs"; + +interface Params_GetDraggedEventDateRange { + eventStart: Dayjs; + durationMin: number; + isAllDay: boolean; +} + +export const getDraggedEventDateRange = ({ + eventStart, + durationMin, + isAllDay, +}: Params_GetDraggedEventDateRange) => { + let adjustedStart = eventStart; + let adjustedEnd = eventStart.add(durationMin, "minutes"); + + if (!isAllDay && adjustedEnd.date() !== adjustedStart.date()) { + adjustedEnd = adjustedEnd.hour(0).minute(0); + adjustedStart = adjustedEnd.subtract(durationMin, "minutes"); + } + + return { + startDate: isAllDay + ? adjustedStart.format(YEAR_MONTH_DAY_FORMAT) + : adjustedStart.format(), + endDate: isAllDay + ? adjustedEnd.format(YEAR_MONTH_DAY_FORMAT) + : adjustedEnd.format(), + }; +}; + +interface Params_GetIsValidResizeMovement { + currTime: Dayjs; + draftStartDate: string; + currentValue?: string; + dateBeingChanged: "startDate" | "endDate" | null; + isAllDay: boolean; +} + +export const getIsValidResizeMovement = ({ + currTime, + draftStartDate, + currentValue, + dateBeingChanged, + isAllDay, +}: Params_GetIsValidResizeMovement) => { + if (!dateBeingChanged) return false; + if (isAllDay) return true; + + const formatted = currTime.format(); + if (currentValue === formatted) return false; + + const isDifferentDay = currTime.day() !== dayjs(draftStartDate).day(); + if (isDifferentDay) return false; + + return formatted !== draftStartDate; +}; diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.test.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.test.ts new file mode 100644 index 000000000..b32af5646 --- /dev/null +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.test.ts @@ -0,0 +1,59 @@ +import { getDraftSubmitAction } from "./draft.submit-decision"; +import { describe, expect, it } from "bun:test"; + +describe("getDraftSubmitAction", () => { + it("creates a new event when the draft has no id", () => { + expect( + getDraftSubmitAction({ + draft: {}, + pendingEventIds: [], + isFormOpenBeforeDragging: false, + isDirty: false, + }), + ).toBe("CREATE"); + }); + + it("discards a pending event update", () => { + expect( + getDraftSubmitAction({ + draft: { _id: "pending-id" }, + pendingEventIds: ["pending-id"], + isFormOpenBeforeDragging: false, + isDirty: true, + }), + ).toBe("DISCARD"); + }); + + it("opens the form again after a drag that started from an open form", () => { + expect( + getDraftSubmitAction({ + draft: { _id: "event-id" }, + pendingEventIds: [], + isFormOpenBeforeDragging: true, + isDirty: true, + }), + ).toBe("OPEN_FORM"); + }); + + it("discards unchanged existing events", () => { + expect( + getDraftSubmitAction({ + draft: { _id: "event-id" }, + pendingEventIds: [], + isFormOpenBeforeDragging: false, + isDirty: false, + }), + ).toBe("DISCARD"); + }); + + it("updates changed existing events", () => { + expect( + getDraftSubmitAction({ + draft: { _id: "event-id" }, + pendingEventIds: [], + isFormOpenBeforeDragging: false, + isDirty: true, + }), + ).toBe("UPDATE"); + }); +}); diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.ts new file mode 100644 index 000000000..59520463b --- /dev/null +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/draft.submit-decision.ts @@ -0,0 +1,25 @@ +export type DraftSubmitAction = "CREATE" | "DISCARD" | "OPEN_FORM" | "UPDATE"; + +type DraftIdentity = { + _id?: string | null; +}; + +interface Params_GetDraftSubmitAction { + draft: DraftIdentity; + pendingEventIds: string[]; + isFormOpenBeforeDragging: boolean | null; + isDirty: boolean; +} + +export const getDraftSubmitAction = ({ + draft, + pendingEventIds, + isFormOpenBeforeDragging, + isDirty, +}: Params_GetDraftSubmitAction): DraftSubmitAction => { + if (!draft._id) return "CREATE"; + if (pendingEventIds.includes(draft._id)) return "DISCARD"; + if (isFormOpenBeforeDragging) return "OPEN_FORM"; + if (!isDirty) return "DISCARD"; + return "UPDATE"; +}; diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts index e608d09e8..39b5bd135 100644 --- a/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/submit.parser.test.ts @@ -66,9 +66,11 @@ mock.module("@web/common/validators/grid.event.validator", () => ({ mock.module("@web/common/validators/someday.event.validator", () => ({ validateSomedayEvent, + validateSomedayEvents: mock((events: Schema_SomedayEvent[]) => events), })); mock.module("@web/common/utils/event/event.util", () => ({ + assembleDefaultEvent: mock(async () => createMockGridEvent()), assembleGridEvent, })); diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts index 7dfbc1ea9..e3e98ae09 100644 --- a/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts +++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts @@ -54,6 +54,11 @@ import { import { type DateCalcs } from "@web/views/Calendar/hooks/grid/useDateCalcs"; import { type WeekProps } from "@web/views/Calendar/hooks/useWeek"; import { GRID_TIME_STEP } from "@web/views/Calendar/layout.constants"; +import { + getDraggedEventDateRange, + getIsValidResizeMovement, +} from "./draft.movement"; +import { getDraftSubmitAction } from "./draft.submit-decision"; import { getDragDurationMinutes } from "./drag-duration.util"; export const useDraftActions = ( @@ -238,31 +243,16 @@ export const useDraftActions = ( const determineSubmitAction = useCallback( (draft: Schema_WebEvent) => { - const isExisting = !!draft._id; - if (!isExisting) return "CREATE"; - - if (isExisting) { - // Prevent updates if event is pending (waiting for backend confirmation) - const isPending = draft._id - ? pendingEventIds.includes(draft._id) - : false; - if (isPending) { - // Event is pending, discard the change and return to original position - return "DISCARD"; - } - - if (isFormOpenBeforeDragging) { - return "OPEN_FORM"; - } - const isSame = reduxDraft - ? !DirtyParser.isEventDirty(draft, reduxDraft) - : false; - if (isSame) { - // no need to make HTTP request - return "DISCARD"; - } - } - return "UPDATE"; + const isDirty = reduxDraft + ? DirtyParser.isEventDirty(draft, reduxDraft) + : true; + + return getDraftSubmitAction({ + draft, + pendingEventIds, + isFormOpenBeforeDragging, + isDirty, + }); }, [reduxDraft, isFormOpenBeforeDragging, pendingEventIds], ); @@ -390,31 +380,22 @@ export const useDraftActions = ( const y = e.clientY - draft.position.dragOffset.y; - let eventStart = dateCalcs.getDateByXY( + const eventStart = dateCalcs.getDateByXY( x, y, weekProps.component.startOfView, ); - let eventEnd = eventStart.add(startEndDurationMin, "minutes"); - - if (!draft.isAllDay) { - // Edge case: timed events' end times can overflow past midnight at the bottom of the grid. - // Below logic prevents that from occurring. - if (eventEnd.date() !== eventStart.date()) { - eventEnd = eventEnd.hour(0).minute(0); - eventStart = eventEnd.subtract(startEndDurationMin, "minutes"); - } - } + const { startDate, endDate } = getDraggedEventDateRange({ + eventStart, + durationMin: startEndDurationMin, + isAllDay: Boolean(draft.isAllDay), + }); const _draft: Schema_GridEvent = { ...draft, - startDate: draft.isAllDay - ? eventStart.format(YEAR_MONTH_DAY_FORMAT) - : eventStart.format(), - endDate: draft.isAllDay - ? eventEnd.format(YEAR_MONTH_DAY_FORMAT) - : eventEnd.format(), + startDate, + endDate, priority: draft.priority || Priorities.UNASSIGNED, }; @@ -458,22 +439,13 @@ export const useDraftActions = ( (currTime: dayjs.Dayjs) => { if (!draft || !dateBeingChanged) return false; - if (draft.isAllDay) { - return true; - } - - const _currTime = currTime.format(); - const noChange = draft[dateBeingChanged] === _currTime; - - if (noChange) return false; - - const diffDay = currTime.day() !== dayjs(draft.startDate).day(); - if (diffDay) return false; - - const sameStart = currTime.format() === draft.startDate; - if (sameStart) return false; - - return true; + return getIsValidResizeMovement({ + currTime, + draftStartDate: draft.startDate, + currentValue: draft[dateBeingChanged], + dateBeingChanged, + isAllDay: Boolean(draft.isAllDay), + }); }, [dateBeingChanged, draft], );