diff --git a/packages/web/package.json b/packages/web/package.json index a0c881314..4d71c05c1 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,6 +26,7 @@ "mini-css-extract-plugin": "^2.3.0", "normalizr": "^3.6.1", "posthog-js": "^1.259.0", + "re-resizable": "^6.11.2", "react": "^18.1.0", "react-cmdk": "^1.3.9", "react-datepicker": "^4.2.1", diff --git a/packages/web/src/common/constants/web.constants.ts b/packages/web/src/common/constants/web.constants.ts index f497828b3..d5f511414 100644 --- a/packages/web/src/common/constants/web.constants.ts +++ b/packages/web/src/common/constants/web.constants.ts @@ -21,6 +21,8 @@ export const ID_SOMEDAY_EVENT_FORM = "Someday Event Form"; export const ID_EVENT_FORM_ACTION_MENU = "event-action-menu"; export const ID_SOMEDAY_EVENT_ACTION_MENU = "someday-event-action-menu"; export const DATA_EVENT_ELEMENT_ID = "data-event-id"; +export const DATA_DRAFT_EVENT = "data-draft-event"; +export const DATA_NEW_DRAFT_EVENT = "data-new-draft-event"; export const DATA_TASK_ELEMENT_ID = "data-task-id"; export const ID_CONTEXT_MENU_ITEMS = "context-menu-items"; export const ID_ADD_TASK_BUTTON = "add-task-button"; diff --git a/packages/web/src/common/context/mouse-position.tsx b/packages/web/src/common/context/mouse-position.tsx index 3b717a82b..eb0f6881c 100644 --- a/packages/web/src/common/context/mouse-position.tsx +++ b/packages/web/src/common/context/mouse-position.tsx @@ -77,7 +77,9 @@ export function getElementAtCursor() { return getElementAtPoint(getCursorPosition()); } -export function isElementInViewport(element: HTMLElement) { +export function isElementInViewport( + element: Pick, +) { const rect = element.getBoundingClientRect(); return ( diff --git a/packages/web/src/common/context/open-at-cursor.tsx b/packages/web/src/common/context/open-at-cursor.tsx deleted file mode 100644 index 95d52c777..000000000 --- a/packages/web/src/common/context/open-at-cursor.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { - Dispatch, - PropsWithChildren, - SetStateAction, - createContext, - useCallback, - useState, -} from "react"; -import { Subject } from "rxjs"; -import { - OpenChangeReason, - Placement, - Strategy, - UseFloatingReturn, - UseInteractionsReturn, - autoUpdate, - flip, - offset, - useClick, - useDismiss, - useFloating, - useFocus, - useHover, - useInteractions, -} from "@floating-ui/react"; -import { theme } from "@web/common/styles/theme"; - -export enum CursorItem { - EventForm = "EventForm", - EventPreview = "EventPreview", - EventContextMenu = "EventContextMenu", -} - -interface OpenAtCursor { - nodeId: CursorItem | null; - floating: UseFloatingReturn; - interactions: UseInteractionsReturn; - setOpen: Dispatch>; - setNodeId: Dispatch>; - setPlacement: Dispatch>; - setStrategy: Dispatch>; - setReference: Dispatch>; - closeOpenAtCursor: () => void; -} - -const themeSpacing = parseInt(theme.spacing.xs); - -export const OpenAtCursorContext = createContext(null); - -export const openedAtCursorChange$ = new Subject< - [boolean, Event | undefined, OpenChangeReason | undefined] ->(); - -export function OpenAtCursorProvider({ children }: PropsWithChildren) { - const [open, setOpen] = useState(false); - const [nodeId, setNodeId] = useState(null); - const [placement, setPlacement] = useState("right-start"); - const [strategy, setStrategy] = useState("absolute"); - const [reference, setReference] = useState(null); - - const closeOpenAtCursor = useCallback(() => { - setPlacement("right-start"); - setNodeId(null); - setReference(null); - }, [setNodeId, setReference, setPlacement]); - - const onOpenChanged = useCallback( - (open: boolean, event?: Event, reason?: OpenChangeReason) => { - setOpen(open); - - if (!open) closeOpenAtCursor(); - - openedAtCursorChange$.next([open, event, reason]); - }, - [closeOpenAtCursor], - ); - - // run this outside of react in future, to avoid unnecessary re-renders - const floating = useFloating({ - open, - placement, - elements: { reference }, - strategy, - middleware: [ - offset(({ rects, placement }) => { - switch (placement) { - case "bottom": - return -rects.reference.height / 2 - rects.floating.height / 2; - default: - return themeSpacing; - } - }), - flip( - ({ placement, initialPlacement }) => { - switch (initialPlacement) { - case "bottom": - return { - fallbackStrategy: "initialPlacement", - fallbackAxisSideDirection: "start", - crossAxis: placement.includes("-"), - }; - case "right-start": - return { - fallbackPlacements: ["left-start"], - fallbackStrategy: "initialPlacement", - }; - default: - return { - fallbackPlacements: [ - "right-start", - "right", - "left", - "top-start", - "bottom-start", - "top", - "bottom", - ], - fallbackStrategy: "bestFit", - }; - } - }, - [nodeId], - ), - ], - onOpenChange: onOpenChanged, - whileElementsMounted: autoUpdate, - }); - - const click = useClick(floating.context); - - const hover = useHover(floating.context, { move: false, delay: 5000 }); - - const focus = useFocus(floating.context, { visibleOnly: true }); - - const dismiss = useDismiss(floating.context); - - const interactions = useInteractions([click, hover, focus, dismiss]); - - return ( - - {children} - - ); -} diff --git a/packages/web/src/common/hooks/useFloatingAtCursor.ts b/packages/web/src/common/hooks/useFloatingAtCursor.ts new file mode 100644 index 000000000..27ce835a7 --- /dev/null +++ b/packages/web/src/common/hooks/useFloatingAtCursor.ts @@ -0,0 +1,80 @@ +import { useCallback, useState } from "react"; +import { + OpenChangeReason, + Placement, + ReferenceType, + UseFloatingOptions, + autoUpdate, + flip, + offset, + useFloating, +} from "@floating-ui/react"; +import { + closeFloatingAtCursor, + useFloatingNodeIdAtCursor, + useFloatingPlacementAtCursor, + useFloatingReferenceAtCursor, + useFloatingStrategyAtCursor, +} from "@web/common/hooks/useOpenAtCursor"; +import { theme } from "@web/common/styles/theme"; + +const themeSpacing = parseInt(theme.spacing.xs); + +const placements: Placement[] = [ + "right-start", + "bottom-start", + "top-start", + "left-start", +]; + +export function useFloatingAtCursor( + onOpenChange?: UseFloatingOptions["onOpenChange"], +): ReturnType { + const [open, setOpen] = useState(false); + const nodeId = useFloatingNodeIdAtCursor() ?? undefined; + const placement = useFloatingPlacementAtCursor(); + const strategy = useFloatingStrategyAtCursor(); + const reference = useFloatingReferenceAtCursor(); + + const handleOpenChange = useCallback( + (open: boolean, event?: Event, reason?: OpenChangeReason) => { + onOpenChange?.(open, event, reason); + + if (!open) closeFloatingAtCursor(); + + setOpen(open); + }, + [onOpenChange], + ); + + const floating = useFloating({ + open, + nodeId, + placement, + strategy, + elements: { reference }, + middleware: [ + offset(({ rects, placement }) => { + switch (placement) { + case "bottom": + case "top": + return -rects.reference.height / 2 - rects.floating.height / 2; + default: + return themeSpacing; + } + }), + flip(({ placement }) => { + return { + fallbackPlacements: placements.filter((p) => p !== placement), + fallbackStrategy: "bestFit", + fallbackAxisSideDirection: "start", + crossAxis: placement.includes("-"), + }; + }), + ], + onOpenChange: handleOpenChange, + whileElementsMounted: autoUpdate, + }); + + return floating; +} diff --git a/packages/web/src/common/hooks/useGridMaxZIndex.ts b/packages/web/src/common/hooks/useGridMaxZIndex.ts new file mode 100644 index 000000000..abdcfb3ad --- /dev/null +++ b/packages/web/src/common/hooks/useGridMaxZIndex.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; +import { distinctUntilChanged, share } from "rxjs/operators"; +import { maxGridZIndex$ } from "@web/common/utils/dom/grid-organization.util"; + +const maxZIndex$ = maxGridZIndex$.pipe(distinctUntilChanged(), share()); + +export function useGridMaxZIndex() { + const [maxZIndex, setMaxZIndex] = useState(maxGridZIndex$.getValue()); + + useEffect(() => { + const subscription = maxZIndex$.subscribe(setMaxZIndex); + + return () => subscription.unsubscribe(); + }, []); + + return maxZIndex; +} diff --git a/packages/web/src/common/hooks/useOpenAtCursor.ts b/packages/web/src/common/hooks/useOpenAtCursor.ts index 8ab051640..572d47d8b 100644 --- a/packages/web/src/common/hooks/useOpenAtCursor.ts +++ b/packages/web/src/common/hooks/useOpenAtCursor.ts @@ -1,25 +1,90 @@ -import { useEffect } from "react"; -import { UseFloatingOptions } from "@floating-ui/react"; -import { - OpenAtCursorContext, - openedAtCursorChange$, -} from "@web/common/context/open-at-cursor"; -import { useMetaContext } from "@web/common/hooks/useMetaContext"; - -export function useOpenAtCursor({ - onOpenChange, -}: Pick = {}) { - const context = useMetaContext(OpenAtCursorContext, "useOpenAtCursor"); +import { useEffect, useState } from "react"; +import { BehaviorSubject, Observable, distinctUntilChanged, share } from "rxjs"; +import { Placement, Strategy } from "@floating-ui/react"; + +export enum CursorItem { + EventForm = "EventForm", + EventPreview = "EventPreview", + EventContextMenu = "EventContextMenu", +} + +const nodeId$ = new BehaviorSubject(null); +const placement$ = new BehaviorSubject("right-start"); +const strategy$ = new BehaviorSubject("absolute"); +const reference$ = new BehaviorSubject(null); +const $nodeId = nodeId$.pipe(distinctUntilChanged(), share()); +const $placement = placement$.pipe(distinctUntilChanged(), share()); +const $strategy = strategy$.pipe(distinctUntilChanged(), share()); +const $reference = reference$.pipe(distinctUntilChanged(), share()); + +function useValue( + subject: BehaviorSubject, + sharedSubject: Observable, +): T { + const [value, setValue] = useState(subject.getValue()); useEffect(() => { - if (onOpenChange) { - const subscription = openedAtCursorChange$.subscribe((args) => - onOpenChange(...args), - ); + const subscription = sharedSubject.subscribe((v) => { + setValue(v); + }); + + return () => subscription.unsubscribe(); + }); + + return value; +} + +export function useFloatingNodeIdAtCursor(): CursorItem | null { + return useValue(nodeId$, $nodeId); +} + +export function useFloatingPlacementAtCursor(): Placement { + return useValue(placement$, $placement); +} + +export function useFloatingStrategyAtCursor(): Strategy { + return useValue(strategy$, $strategy); +} + +export function useFloatingReferenceAtCursor(): Element | null { + return useValue(reference$, $reference); +} + +export function setFloatingNodeIdAtCursor(nodeId: CursorItem | null) { + nodeId$.next(nodeId); +} + +export function setFloatingPlacementAtCursor(placement: Placement) { + placement$.next(placement); +} + +export function setFloatingStrategyAtCursor(strategy: Strategy) { + strategy$.next(strategy); +} - return () => subscription.unsubscribe(); - } - }, [onOpenChange]); +export function setFloatingReferenceAtCursor(reference: Element | null) { + reference$.next(reference); +} + +export function openFloatingAtCursor({ + nodeId, + reference, + placement = "right-start", + strategy = "absolute", +}: { + nodeId: CursorItem; + reference: Element; + placement?: Placement; + strategy?: Strategy; +}) { + setFloatingNodeIdAtCursor(nodeId); + setFloatingPlacementAtCursor(placement); + setFloatingStrategyAtCursor(strategy); + setFloatingReferenceAtCursor(reference); +} - return context; +export function closeFloatingAtCursor() { + setFloatingNodeIdAtCursor(null); + setFloatingPlacementAtCursor("right-start"); + setFloatingReferenceAtCursor(null); } diff --git a/packages/web/src/common/utils/dom/event-emitter.util.ts b/packages/web/src/common/utils/dom/event-emitter.util.ts index 38b41a570..be5369830 100644 --- a/packages/web/src/common/utils/dom/event-emitter.util.ts +++ b/packages/web/src/common/utils/dom/event-emitter.util.ts @@ -19,6 +19,7 @@ export interface DomMovement { export enum CompassDOMEvents { FOCUS_TASK_DESCRIPTION = "FOCUS_TASK_DESCRIPTION", SAVE_TASK_DESCRIPTION = "SAVE_TASK_DESCRIPTION", + SCROLL_TO_NOW_LINE = "SCROLL_TO_NOW_LINE", } export const compassEventEmitter = new EventEmitter2({ diff --git a/packages/web/src/common/utils/dom/grid-organization.util.ts b/packages/web/src/common/utils/dom/grid-organization.util.ts index 58c3fcacd..8eb968909 100644 --- a/packages/web/src/common/utils/dom/grid-organization.util.ts +++ b/packages/web/src/common/utils/dom/grid-organization.util.ts @@ -19,8 +19,9 @@ export const gridOrganization$ = new BehaviorSubject>( {}, ); -export const maxAgendaZIndex$ = new BehaviorSubject(0); +export const maxGridZIndex$ = new BehaviorSubject(0); +const borderRingSpace = 2; const themeSpacing = parseInt(theme.spacing.s); const canvas = document.createElement("canvas"); const canvasContext = canvas.getContext("2d"); @@ -39,7 +40,10 @@ export const checkAABBCollision = (rectA: DOMRect, rectB: DOMRect): boolean => { } // Check if there is no overlap on the Y-axis - if (rectA.y + rectA.height <= rectB.y || rectB.y + rectB.height <= rectA.y) { + if ( + rectA.y + rectA.height - borderRingSpace <= rectB.y || + rectB.y + rectB.height - borderRingSpace <= rectA.y + ) { return false; // No collision } @@ -102,7 +106,6 @@ export const findOptimalPlacement = ( containerWidth: number, _width: number, ): Placement => { - const borderRingSpace = 2; const width = `${_width}px`; const collisionLength = collidingNodes.length; const isOverlapping = collisionLength > 0; @@ -193,7 +196,7 @@ function updatePlacements( const { zIndex, ...styles } = placement.style; // overlaps should typically not exceed 10 events. // support for up to 100 z-index overlap classes has been added in index.css - const zClass = `z-${zIndex}`; + const zClass = `z-${zIndex ?? 0}`; const zMaxClass = `z-${maxZIndex}`; const classLists = Array.from(node.classList.values()); const overlapClasses = ["border", "shadow-md"]; @@ -238,7 +241,7 @@ export function reorderGrid(mainGrid: HTMLElement) { const maxZIndex = Math.max(...zIndexes) + 1; - maxAgendaZIndex$.next(maxZIndex); + maxGridZIndex$.next(maxZIndex); const organization = updatePlacements(nodes, placements, maxZIndex); diff --git a/packages/web/src/common/utils/event/event.util.ts b/packages/web/src/common/utils/event/event.util.ts index 6fef7fc72..09c35578d 100644 --- a/packages/web/src/common/utils/event/event.util.ts +++ b/packages/web/src/common/utils/event/event.util.ts @@ -14,13 +14,24 @@ import { } from "@core/types/event.types"; import dayjs, { Dayjs } from "@core/util/date/dayjs"; import { getUserId } from "@web/auth/auth.util"; -import { DATA_EVENT_ELEMENT_ID } from "@web/common/constants/web.constants"; +import { + CLASS_TIMED_CALENDAR_EVENT, + DATA_EVENT_ELEMENT_ID, + ID_GRID_ALLDAY_ROW, + ID_GRID_EVENTS_TIMED, + ID_GRID_MAIN, +} from "@web/common/constants/web.constants"; +import { isElementInViewport } from "@web/common/context/mouse-position"; import { PartialMouseEvent } from "@web/common/types/util.types"; import { Schema_GridEvent, Schema_OptimisticEvent, Schema_WebEvent, } from "@web/common/types/web.event.types"; +import { + focusElement, + getFocusedEvent, +} from "@web/views/Day/util/agenda/focus.util"; export const gridEventDefaultPosition: Schema_GridEvent["position"] = { isOverlapping: false, @@ -131,6 +142,63 @@ export const getCalendarEventIdFromElement = (element: HTMLElement) => { return eventElement ? eventElement.getAttribute(DATA_EVENT_ELEMENT_ID) : null; }; +export const getCalendarEventElementFromGrid = ( + eventId: string, +): Element | null => { + const selector = `[${DATA_EVENT_ELEMENT_ID}="${eventId}"]`; + const allDaySection = document.getElementById(ID_GRID_ALLDAY_ROW); + const timedSection = document.getElementById(ID_GRID_MAIN); + const timedEvent = timedSection?.querySelector(selector); + + return timedEvent ?? allDaySection?.querySelector(selector) ?? null; +}; + +export function openEventFormCreateEvent() { + const domEvent = new CustomEvent("click", { + bubbles: true, + detail: { create: true }, + }); + + const calendarSurface = document.getElementById(ID_GRID_MAIN); + + if (!calendarSurface) return; + + calendarSurface.dispatchEvent(domEvent); +} + +export function openEventFormEditEvent() { + const event = getFocusedEvent(); + + if (!event) return; + + const id = event.getAttribute(DATA_EVENT_ELEMENT_ID); + + const domEvent = new CustomEvent("click", { + bubbles: true, + detail: { create: false, id }, + }); + + const isTimedEvent = event.classList.contains(CLASS_TIMED_CALENDAR_EVENT); + + if (isTimedEvent) { + const timedSurface = document.getElementById(ID_GRID_EVENTS_TIMED); + const willScroll = !isElementInViewport(event); + + if (!willScroll) return event.dispatchEvent(domEvent); + focusElement(event); + + return timedSurface?.addEventListener( + "scrollend", + () => event.dispatchEvent(domEvent), + { + once: true, + }, + ); + } + + event.dispatchEvent(domEvent); +} + export const getMonthListLabel = (start: Dayjs) => { return start.format("MMMM"); }; @@ -235,3 +303,7 @@ const _assembleBaseEvent = ( return baseEvent; }; + +export function compareEventsByTitle(a: Schema_Event, b: Schema_Event) { + return (a.title ?? "").localeCompare(b.title ?? ""); +} diff --git a/packages/web/src/components/DND/Draggable.tsx b/packages/web/src/components/DND/Draggable.tsx index da920cf5b..ff27f65a9 100644 --- a/packages/web/src/components/DND/Draggable.tsx +++ b/packages/web/src/components/DND/Draggable.tsx @@ -4,9 +4,11 @@ import { ForwardedRef, HTMLAttributes, ReactHTML, + cloneElement, createElement, forwardRef, - useMemo, + isValidElement, + useState, } from "react"; import { UniqueIdentifier, @@ -32,38 +34,56 @@ function CompassDraggable( data: DraggableDNDData; }; as: keyof ReactHTML; + asChild?: boolean; } & HTMLAttributes, HTMLElement >, _ref: ForwardedRef, ) { - const { dndProps, as, style, onContextMenu, ...elementProps } = props; + const [disabled, setDisabled] = useState(!!props.dndProps.disabled); - const { attributes, listeners, setNodeRef, transform, isDragging } = + const { + dndProps, + as, + asChild, + style, + onContextMenu, + children, + ...elementProps + } = props; + + const { setNodeRef, attributes, listeners, transform, isDragging, over } = useDraggable({ ...props.dndProps, id: props.dndProps.id ?? new ObjectId().toString(), + disabled, }); const ref = useMergeRefs([_ref, setNodeRef]); - - const dndStyle = useMemo(() => { - if (!transform) return {}; - - return { transform: CSS.Translate.toString(transform) }; - }, [transform]); + const useChild = asChild && isValidElement(children); return createElement(as ?? "div", { ...elementProps, - ...listeners, ...attributes, + ...(!useChild ? listeners : {}), onContextMenu: isDragging ? undefined : onContextMenu, style: { ...style, - ...dndStyle, + transform: CSS.Translate.toString(transform), ...(isDragging ? { opacity: 0 } : {}), }, ref, + children: useChild + ? cloneElement(children, { + ...children.props, + dndProps: { + over, + listeners, + isDragging, + setDisabled, + }, + }) + : props.children, }); } diff --git a/packages/web/src/components/DND/Resizable.tsx b/packages/web/src/components/DND/Resizable.tsx new file mode 100644 index 000000000..676191e81 --- /dev/null +++ b/packages/web/src/components/DND/Resizable.tsx @@ -0,0 +1,36 @@ +import { Resizable as ReResizable, ResizableProps } from "re-resizable"; +import { + Dispatch, + PropsWithChildren, + SetStateAction, + cloneElement, + isValidElement, +} from "react"; +import { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"; + +export function Resizable({ + children, + dndProps, + ...props +}: PropsWithChildren< + ResizableProps & { + dndProps?: { + listeners?: SyntheticListenerMap; + setDisabled: Dispatch>; + isDragging: boolean; + }; + } +>) { + const isValidChildren = isValidElement(children); + + if (!isValidChildren) return null; + + return ( + + {cloneElement(children, { + ...children.props, + ...(dndProps ? { dndProps } : {}), + })} + + ); +} diff --git a/packages/web/src/components/FloatingEventForm/FloatingEventForm.tsx b/packages/web/src/components/FloatingEventForm/FloatingEventForm.tsx index 5e3ae3eea..e033d7a7f 100644 --- a/packages/web/src/components/FloatingEventForm/FloatingEventForm.tsx +++ b/packages/web/src/components/FloatingEventForm/FloatingEventForm.tsx @@ -1,36 +1,62 @@ -import { FloatingFocusManager, FloatingPortal } from "@floating-ui/react"; -import { CursorItem } from "@web/common/context/open-at-cursor"; -import { useOpenAtCursor } from "@web/common/hooks/useOpenAtCursor"; -import { maxAgendaZIndex$ } from "@web/common/utils/dom/grid-organization.util"; -import { useDraftContextV2 } from "@web/views/Calendar/components/Draft/context/useDraftContextV2"; +import { + FloatingFocusManager, + FloatingPortal, + UseInteractionsReturn, + useFloating, +} from "@floating-ui/react"; +import { useGridMaxZIndex } from "@web/common/hooks/useGridMaxZIndex"; +import { + CursorItem, + useFloatingNodeIdAtCursor, +} from "@web/common/hooks/useOpenAtCursor"; +import { + setDraft, + useDraft, +} from "@web/views/Calendar/components/Draft/context/useDraft"; import { EventForm } from "@web/views/Forms/EventForm/EventForm"; +import { useCloseEventForm } from "@web/views/Forms/hooks/useCloseEventForm"; +import { useSaveEventForm } from "@web/views/Forms/hooks/useSaveEventForm"; -export function FloatingEventForm() { - const context = useDraftContextV2(); - const zIndex = maxAgendaZIndex$.getValue() + 1; - const openAtCursor = useOpenAtCursor(); - const { nodeId, floating, interactions, closeOpenAtCursor } = openAtCursor; +export function FloatingEventForm({ + floating, + interactions, +}: { + floating: ReturnType; + interactions: UseInteractionsReturn; +}) { + const draft = useDraft(); + const nodeId = useFloatingNodeIdAtCursor(); + const floatingContextOpen = floating.context.open; + const onSave = useSaveEventForm(); + const onClose = useCloseEventForm(); + const maxZIndex = useGridMaxZIndex(); const isOpenAtCursor = nodeId === CursorItem.EventForm; - const { draft, onDelete, onSave, setDraft } = context; + const open = floatingContextOpen && isOpenAtCursor && !!draft; - if (!isOpenAtCursor || !draft) return null; + if (!open) return null; return ( - +
{}} onSubmit={onSave} setEvent={setDraft} /> diff --git a/packages/web/src/components/Shortcuts/ShortcutOverlay/ShortcutsOverlay.tsx b/packages/web/src/components/Shortcuts/ShortcutOverlay/ShortcutsOverlay.tsx index 785d0a497..d9daac96b 100644 --- a/packages/web/src/components/Shortcuts/ShortcutOverlay/ShortcutsOverlay.tsx +++ b/packages/web/src/components/Shortcuts/ShortcutOverlay/ShortcutsOverlay.tsx @@ -1,6 +1,6 @@ import classNames from "classnames"; +import { useGridMaxZIndex } from "@web/common/hooks/useGridMaxZIndex"; import { Shortcut } from "@web/common/types/global.shortcut.types"; -import { maxAgendaZIndex$ } from "@web/common/utils/dom/grid-organization.util"; import { ShortcutSection } from "./ShortcutSection"; export interface ShortcutOverlaySection { @@ -22,6 +22,8 @@ export const ShortcutsOverlay = ({ ariaLabel = "Shortcut overlay", className = "", }: ShortcutsOverlayProps) => { + const maxZIndex = useGridMaxZIndex(); + const visibleSections = sections.filter( (section) => section.shortcuts.length > 0, ); @@ -37,7 +39,7 @@ export const ShortcutsOverlay = ({ "border p-3 shadow-lg backdrop-blur-sm md:block", `hidden w-[240px] rounded-lg ${className}`, )} - style={{ zIndex: maxAgendaZIndex$.getValue() }} + style={{ zIndex: maxZIndex }} > {heading && (
diff --git a/packages/web/src/views/Calendar/components/Draft/context/DraftProviderV2.tsx b/packages/web/src/views/Calendar/components/Draft/context/DraftProviderV2.tsx deleted file mode 100644 index 364eed15a..000000000 --- a/packages/web/src/views/Calendar/components/Draft/context/DraftProviderV2.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { - Dispatch, - FocusEvent, - MouseEvent, - PropsWithChildren, - SetStateAction, - createContext, - useCallback, - useState, -} from "react"; -import { Schema_Event } from "@core/types/event.types"; -import { useOpenAtCursor } from "@web/common/hooks/useOpenAtCursor"; -import { useOpenAgendaEventPreview } from "@web/views/Day/hooks/events/useOpenAgendaEventPreview"; -import { useOpenEventContextMenu } from "@web/views/Day/hooks/events/useOpenEventContextMenu"; -import { useOpenEventForm } from "@web/views/Forms/hooks/useOpenEventForm"; -import { useSaveEventForm } from "@web/views/Forms/hooks/useSaveEventForm"; - -interface DraftProviderV2Props { - draft: Schema_Event | null; - existing: boolean; - setDraft: Dispatch>; - setExisting: Dispatch>; - openAgendaEventPreview: ReturnType; - openEventContextMenu: ReturnType; - openEventForm: ReturnType; - closeOpenAtCursor: () => void; - handleCloseOpenAtCursor: ( - e: MouseEvent | FocusEvent, - ) => void; - onDelete: () => void; - onSave: (draft: Schema_Event | null) => void; -} - -export const DraftContextV2 = createContext(null); - -export function DraftProviderV2({ children }: PropsWithChildren) { - const [existing, setExisting] = useState(false); - const [draft, setDraft] = useState(null); - - const onOpenChange = useCallback( - (open: boolean) => { - if (!open) setDraft(null); - }, - [setDraft], - ); - - const openAtCursor = useOpenAtCursor({ onOpenChange }); - - const openEventForm = useOpenEventForm({ setDraft, setExisting }); - - const openAgendaEventPreview = useOpenAgendaEventPreview({ setDraft }); - - const openEventContextMenu = useOpenEventContextMenu({ setDraft }); - - const { closeOpenAtCursor } = openAtCursor; - - const handleCloseOpenAtCursor = useCallback( - (e: MouseEvent | FocusEvent) => { - e.preventDefault(); - e.stopPropagation(); - closeOpenAtCursor(); - }, - [closeOpenAtCursor], - ); - - const onSave = useSaveEventForm({ - existing, - closeEventForm: closeOpenAtCursor, - }); - - const onDelete = useCallback(() => {}, []); - - return ( - - {children} - - ); -} diff --git a/packages/web/src/views/Calendar/components/Draft/context/__tests__/DraftProviderV2.test.tsx b/packages/web/src/views/Calendar/components/Draft/context/__tests__/DraftProviderV2.test.tsx deleted file mode 100644 index c8fca23ba..000000000 --- a/packages/web/src/views/Calendar/components/Draft/context/__tests__/DraftProviderV2.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useContext } from "react"; -import { render, screen } from "@testing-library/react"; -import { useOpenAtCursor } from "@web/common/hooks/useOpenAtCursor"; -import { - DraftContextV2, - DraftProviderV2, -} from "@web/views/Calendar/components/Draft/context/DraftProviderV2"; -import { useOpenEventForm } from "@web/views/Forms/hooks/useOpenEventForm"; -import { useSaveEventForm } from "@web/views/Forms/hooks/useSaveEventForm"; - -jest.mock("@web/common/hooks/useOpenAtCursor"); -jest.mock("@web/views/Forms/hooks/useOpenEventForm"); -jest.mock("@web/views/Forms/hooks/useSaveEventForm"); -jest.mock("@web/views/Day/hooks/events/useOpenAgendaEventPreview"); -jest.mock("@web/views/Day/hooks/events/useOpenEventContextMenu"); - -const mockSetOpenAtMousePosition = jest.fn(); -const mockOpenEventForm = jest.fn(); -const mockOnSave = jest.fn(); -const mockCloseOpenAtCursor = jest.fn(); - -(useOpenAtCursor as jest.Mock).mockReturnValue({ - setOpenAtMousePosition: mockSetOpenAtMousePosition, - closeOpenAtCursor: mockCloseOpenAtCursor, -}); -(useOpenEventForm as jest.Mock).mockReturnValue(mockOpenEventForm); -(useSaveEventForm as jest.Mock).mockReturnValue(mockOnSave); - -const TestComponent = () => { - const context = useContext(DraftContextV2); - if (!context) return
No Context
; - return ( -
-
- {context.draft ? "Draft Exists" : "No Draft"} -
- - - -
- ); -}; - -describe("DraftProviderV2", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it("should provide draft context values", () => { - render( - - - , - ); - - expect(screen.getByTestId("draft")).toHaveTextContent("No Draft"); - - screen.getByText("Open").click(); - expect(mockOpenEventForm).toHaveBeenCalled(); - - screen.getByText("Close").click(); - expect(mockCloseOpenAtCursor).toHaveBeenCalled(); - - screen.getByText("Save").click(); - expect(mockOnSave).toHaveBeenCalled(); - }); -}); diff --git a/packages/web/src/views/Calendar/components/Draft/context/useDraft.test.ts b/packages/web/src/views/Calendar/components/Draft/context/useDraft.test.ts new file mode 100644 index 000000000..29afcb427 --- /dev/null +++ b/packages/web/src/views/Calendar/components/Draft/context/useDraft.test.ts @@ -0,0 +1,61 @@ +import { act } from "react"; +import { renderHook } from "@testing-library/react"; +import { Schema_Event } from "@core/types/event.types"; +import { + draft$, + setDraft, + useDraft, +} from "@web/views/Calendar/components/Draft/context/useDraft"; + +describe("useDraft", () => { + const mockEvent: Schema_Event = { + _id: "123", + title: "Test Event", + startDate: "2023-01-01", + endDate: "2023-01-01", + }; + + beforeEach(() => { + // Reset the subject before each test + draft$.next(null); + }); + + it("should return null initially", () => { + const { result } = renderHook(() => useDraft()); + expect(result.current).toBeNull(); + }); + + it("should update when setDraft is called", () => { + const { result } = renderHook(() => useDraft()); + + act(() => { + setDraft(mockEvent); + }); + + expect(result.current).toEqual(mockEvent); + }); + + it("should update when draft$ subject emits", () => { + const { result } = renderHook(() => useDraft()); + + act(() => { + draft$.next(mockEvent); + }); + + expect(result.current).toEqual(mockEvent); + }); + + it("should handle setting draft to null", () => { + const { result } = renderHook(() => useDraft()); + + act(() => { + setDraft(mockEvent); + }); + expect(result.current).toEqual(mockEvent); + + act(() => { + setDraft(null); + }); + expect(result.current).toBeNull(); + }); +}); diff --git a/packages/web/src/views/Calendar/components/Draft/context/useDraft.ts b/packages/web/src/views/Calendar/components/Draft/context/useDraft.ts new file mode 100644 index 000000000..e5b1f7189 --- /dev/null +++ b/packages/web/src/views/Calendar/components/Draft/context/useDraft.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react"; +import { BehaviorSubject } from "rxjs"; +import { Schema_Event } from "@core/types/event.types"; + +export const draft$ = new BehaviorSubject(null); + +export const setDraft = (event: Schema_Event | null) => draft$.next(event); + +export const useDraft = () => { + const [draft, _setDraft] = useState(draft$.getValue()); + + useEffect(() => { + const subscription = draft$.subscribe(_setDraft); + + return () => subscription.unsubscribe(); + }, [_setDraft]); + + return draft; +}; diff --git a/packages/web/src/views/Calendar/components/Draft/context/useDraftContextV2.ts b/packages/web/src/views/Calendar/components/Draft/context/useDraftContextV2.ts deleted file mode 100644 index 1eeddc7ef..000000000 --- a/packages/web/src/views/Calendar/components/Draft/context/useDraftContextV2.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useContext } from "react"; -import { DraftContextV2 } from "@web/views/Calendar/components/Draft/context/DraftProviderV2"; - -export const useDraftContextV2 = () => { - const context = useContext(DraftContextV2); - - if (!context) { - throw new Error("useDraftContext must be used within DraftProviderV2"); - } - - return context; -}; diff --git a/packages/web/src/views/Day/components/Agenda/Agenda.test.tsx b/packages/web/src/views/Day/components/Agenda/Agenda.test.tsx index dc78160ad..3ae65319c 100644 --- a/packages/web/src/views/Day/components/Agenda/Agenda.test.tsx +++ b/packages/web/src/views/Day/components/Agenda/Agenda.test.tsx @@ -3,15 +3,15 @@ import "@testing-library/jest-dom"; import { screen } from "@testing-library/react"; import { Schema_Event } from "@core/types/event.types"; import { createStoreWithEvents } from "@web/__tests__/utils/state/store.test.util"; -import { useDraftContextV2 } from "@web/views/Calendar/components/Draft/context/useDraftContextV2"; import { Agenda } from "@web/views/Day/components/Agenda/Agenda"; import { renderWithDayProviders } from "@web/views/Day/util/day.test-util"; +import { useOpenEventForm } from "@web/views/Forms/hooks/useOpenEventForm"; jest.mock("@web/auth/auth.util", () => ({ getUserId: jest.fn().mockResolvedValue("user-123"), })); -jest.mock("@web/views/Calendar/components/Draft/context/useDraftContextV2"); +jest.mock("@web/views/Forms/hooks/useOpenEventForm"); const renderAgenda = ( events: Schema_Event[] = [], @@ -24,9 +24,7 @@ const renderAgenda = ( describe("CalendarAgenda", () => { beforeEach(() => { - (useDraftContextV2 as jest.Mock).mockReturnValue({ - openEventForm: jest.fn(), - }); + (useOpenEventForm as jest.Mock).mockReturnValue(jest.fn()); }); it("should render time labels", async () => { @@ -217,9 +215,7 @@ describe("CalendarAgenda", () => { it("should open event form when pressing Enter on timed events section", async () => { const openEventFormMock = jest.fn(); - (useDraftContextV2 as jest.Mock).mockReturnValue({ - openEventForm: openEventFormMock, - }); + (useOpenEventForm as jest.Mock).mockReturnValue(openEventFormMock); const { user } = await act(() => renderAgenda()); @@ -234,9 +230,7 @@ describe("CalendarAgenda", () => { it("should open event form when pressing Enter on all-day events section", async () => { const openEventFormMock = jest.fn(); - (useDraftContextV2 as jest.Mock).mockReturnValue({ - openEventForm: openEventFormMock, - }); + (useOpenEventForm as jest.Mock).mockReturnValue(openEventFormMock); const { user } = await act(() => renderAgenda()); diff --git a/packages/web/src/views/Day/components/Agenda/Agenda.tsx b/packages/web/src/views/Day/components/Agenda/Agenda.tsx index 8bab89557..01d9fb1b2 100644 --- a/packages/web/src/views/Day/components/Agenda/Agenda.tsx +++ b/packages/web/src/views/Day/components/Agenda/Agenda.tsx @@ -1,67 +1,52 @@ import classNames from "classnames"; -import fastDeepEqual from "fast-deep-equal/react"; -import { - ForwardedRef, - forwardRef, - memo, - useCallback, - useEffect, - useImperativeHandle, - useRef, -} from "react"; -import { useLocation } from "react-router-dom"; +import { memo, useCallback, useRef } from "react"; import { Key } from "ts-key-enum"; import { ID_GRID_EVENTS_TIMED } from "@web/common/constants/web.constants"; +import { useFloatingAtCursor } from "@web/common/hooks/useFloatingAtCursor"; +import { FloatingEventForm } from "@web/components/FloatingEventForm/FloatingEventForm"; import { selectDayEvents } from "@web/ducks/events/selectors/event.selectors"; import { useAppSelector } from "@web/store/store.hooks"; -import { useDraftContextV2 } from "@web/views/Calendar/components/Draft/context/useDraftContextV2"; +import { setDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; +import { AgendaEventPreview } from "@web/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview"; import { AllDayAgendaEvents } from "@web/views/Day/components/Agenda/Events/AllDayAgendaEvent/AllDayAgendaEvents"; import { TimedAgendaEvents } from "@web/views/Day/components/Agenda/Events/TimedAgendaEvent/TimedAgendaEvents"; import { NowLine } from "@web/views/Day/components/Agenda/NowLine/NowLine"; import { TimeLabels } from "@web/views/Day/components/Agenda/TimeLabels/TimeLabels"; +import { EventContextMenu } from "@web/views/Day/components/ContextMenu/EventContextMenu"; +import { useAgendaInteractionsAtCursor } from "@web/views/Day/hooks/events/useAgendaInteractionsAtCursor"; -export const Agenda = memo( - forwardRef((_: {}, ref: ForwardedRef<{ scrollToNow: () => void }>) => { - const { pathname } = useLocation(); - const { openEventForm } = useDraftContextV2(); - const nowLineRef = useRef(null); - const events = useAppSelector(selectDayEvents); - const height = useRef(0); +const openChange = (open: boolean) => { + if (!open) setDraft(null); +}; - // Separate all-day events from timed events - const allDayEvents = events.filter((event) => event.isAllDay); +export const Agenda = memo(function Agenda() { + const events = useAppSelector(selectDayEvents); + const height = useRef(0); + const floating = useFloatingAtCursor(openChange); + const interactions = useAgendaInteractionsAtCursor(floating); + const timedAgendaRef = useRef(null); - const scrollToNow = useCallback(() => { - nowLineRef.current?.scrollIntoView({ - block: "center", - inline: "nearest", - behavior: "smooth", - }); - }, []); + // Separate all-day events from timed events + const allDayEvents = events.filter((event) => event.isAllDay); - const onEnterKey = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === Key.Enter) { - e.preventDefault(); - e.stopPropagation(); - openEventForm(); - } - }, - [openEventForm], - ); + const onEnterKey = useCallback((e: React.KeyboardEvent) => { + if (e.key === Key.Enter) { + e.preventDefault(); + e.stopPropagation(); + timedAgendaRef.current?.click(); + } + }, []); - // Provide the scroll function to parent component - useImperativeHandle(ref, () => ({ scrollToNow }), [scrollToNow]); - - // Center the calendar around the current time when the view mounts - useEffect(() => scrollToNow(), [scrollToNow, pathname]); - - return ( + return ( + <>
- +
0 ? {} : { title: "Timed calendar events section" })} - onKeyDown={onEnterKey} style={{ overscrollBehavior: "contain", scrollbarGutter: "stable both-edges", @@ -90,14 +75,21 @@ export const Agenda = memo( > - + - +
- ); - }), - fastDeepEqual, -); + + + + + + ); +}); Agenda.displayName = "Agenda"; diff --git a/packages/web/src/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview.tsx b/packages/web/src/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview.tsx index 29fd48d3d..824c768c7 100644 --- a/packages/web/src/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview.tsx +++ b/packages/web/src/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview.tsx @@ -1,21 +1,32 @@ -import { FloatingPortal } from "@floating-ui/react"; +import { + FloatingPortal, + UseInteractionsReturn, + useFloating, +} from "@floating-ui/react"; import { Priorities } from "@core/constants/core.constants"; import { darken, isDark } from "@core/util/color.utils"; -import { CursorItem } from "@web/common/context/open-at-cursor"; -import { useOpenAtCursor } from "@web/common/hooks/useOpenAtCursor"; +import { useGridMaxZIndex } from "@web/common/hooks/useGridMaxZIndex"; +import { + CursorItem, + useFloatingNodeIdAtCursor, +} from "@web/common/hooks/useOpenAtCursor"; import { colorByPriority } from "@web/common/styles/theme.util"; -import { maxAgendaZIndex$ } from "@web/common/utils/dom/grid-organization.util"; -import { useDraftContextV2 } from "@web/views/Calendar/components/Draft/context/useDraftContextV2"; +import { useDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; import { getAgendaEventTime } from "@web/views/Day/util/agenda/agenda.util"; -export function AgendaEventPreview() { - const context = useDraftContextV2(); - const zIndex = maxAgendaZIndex$.getValue() + 1; - const openAtCursor = useOpenAtCursor(); - const { draft } = context; - const { nodeId, floating, interactions } = openAtCursor; +export function AgendaEventPreview({ + floating, + interactions, +}: { + floating: ReturnType; + interactions: UseInteractionsReturn; +}) { + const draft = useDraft(); + const nodeId = useFloatingNodeIdAtCursor(); + const floatingContextOpen = floating.context.open; + const maxZIndex = useGridMaxZIndex(); const isOpenAtCursor = nodeId === CursorItem.EventPreview; - + const open = floatingContextOpen && isOpenAtCursor && !!draft; const priority = draft?.priority || Priorities.UNASSIGNED; const priorityColor = colorByPriority[priority]; const darkPriorityColor = darken(priorityColor); @@ -26,7 +37,7 @@ export function AgendaEventPreview() { ? `${getAgendaEventTime(draft.startDate)} - ${getAgendaEventTime(draft.endDate)}` : ""; - if (!isOpenAtCursor || !draft) return null; + if (!open) return null; return ( @@ -40,7 +51,7 @@ export function AgendaEventPreview() { style={{ ...floating.context.floatingStyles, backgroundColor: darkPriorityColor, - zIndex, + zIndex: maxZIndex + 1, }} >
diff --git a/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/AllDayAgendaEvents.tsx b/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/AllDayAgendaEvents.tsx index 84f19f77d..c062663f9 100644 --- a/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/AllDayAgendaEvents.tsx +++ b/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/AllDayAgendaEvents.tsx @@ -1,65 +1,98 @@ import classNames from "classnames"; -import { useCallback } from "react"; +import fastDeepEqual from "fast-deep-equal/react"; +import { PointerEvent, memo, useCallback, useMemo } from "react"; import { Key } from "ts-key-enum"; +import { UseInteractionsReturn } from "@floating-ui/react"; import { Schema_Event } from "@core/types/event.types"; +import { StringV4Schema } from "@core/types/type.utils"; import { ID_GRID_ALLDAY_ROW } from "@web/common/constants/web.constants"; import { Schema_GridEvent } from "@web/common/types/web.event.types"; +import { compareEventsByTitle } from "@web/common/utils/event/event.util"; import { Droppable } from "@web/components/DND/Droppable"; -import { useDraftContextV2 } from "@web/views/Calendar/components/Draft/context/useDraftContextV2"; +import { useDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; import { DraggableAllDayAgendaEvent } from "@web/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent"; +import { useOpenEventForm } from "@web/views/Forms/hooks/useOpenEventForm"; -export const AllDayAgendaEvents = ({ - allDayEvents, -}: { - allDayEvents: Schema_Event[]; -}) => { - const { openEventForm } = useDraftContextV2(); +export const AllDayAgendaEvents = memo( + ({ + allDayEvents, + interactions, + }: { + allDayEvents: Schema_Event[]; + interactions: UseInteractionsReturn; + }) => { + const draft = useDraft(); + const openEventForm = useOpenEventForm(); - // Sort all-day events by title for consistent TAB order - const sortedAllDayEvents = [...allDayEvents].sort((a, b) => - (a.title || "").localeCompare(b.title || ""), - ); + // Sort all-day events by title for consistent TAB order + const sortedAllDayEvents = useMemo( + () => [...allDayEvents].sort(compareEventsByTitle), + [allDayEvents], + ); - const onEnterKey = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === Key.Enter) { - e.preventDefault(); - e.stopPropagation(); - openEventForm(); + const events = useMemo(() => { + if (!draft || !StringV4Schema.safeParse(draft._id).success) { + return sortedAllDayEvents; } - }, - [openEventForm], - ); - return ( - 0 ? {} : { title: "All-day events section" })} - className={classNames( - "group flex max-h-36 min-h-8 cursor-cell flex-col gap-1 pt-2", - "overflow-x-hidden overflow-y-auto", - "border-t border-gray-400/20", - "focus-visible:rounded focus-visible:ring-2", - "focus:outline-none focus-visible:ring-yellow-200", - )} - style={{ - overscrollBehavior: "contain", - scrollbarGutter: "stable both-edges", - }} - onClick={() => openEventForm()} - onKeyDown={onEnterKey} - > - {sortedAllDayEvents.map((event) => ( - - ))} - - ); -}; + const withoutDraft = sortedAllDayEvents.filter( + (event) => event._id !== draft._id, + ); + + const allEvents = [draft, ...withoutDraft]; + + return allEvents.sort(compareEventsByTitle); + }, [sortedAllDayEvents, draft]); + + const onEnterKey = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === Key.Enter) { + e.preventDefault(); + e.stopPropagation(); + openEventForm(e as unknown as PointerEvent); + } + }, + [openEventForm], + ); + + return ( + 0 + ? {} + : { title: "All-day events section" })} + className={classNames( + "group flex max-h-36 min-h-8 cursor-cell flex-col gap-1 pt-2", + "overflow-x-hidden overflow-y-auto", + "border-t border-gray-400/20", + "focus-visible:rounded focus-visible:ring-2", + "focus:outline-none focus-visible:ring-yellow-200", + )} + style={{ + overscrollBehavior: "contain", + scrollbarGutter: "stable both-edges", + }} + > + {events.map((event) => ( + e._id === event?._id)} + /> + ))} + + ); + }, + fastDeepEqual, +); diff --git a/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent.test.tsx b/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent.test.tsx index 7789af9fa..c28f45dbd 100644 --- a/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent.test.tsx +++ b/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent.test.tsx @@ -1,24 +1,10 @@ -import { Provider } from "react-redux"; import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory"; -import { OpenAtCursorProvider } from "@web/common/context/open-at-cursor"; +import { render, screen } from "@web/__tests__/__mocks__/mock.render"; import { Schema_GridEvent } from "@web/common/types/web.event.types"; import { gridEventDefaultPosition } from "@web/common/utils/event/event.util"; -import { store } from "@web/store"; -import { DraftProviderV2 } from "@web/views/Calendar/components/Draft/context/DraftProviderV2"; import { DraggableAllDayAgendaEvent } from "@web/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent"; -function renderWithMenuProvider(ui: React.ReactElement) { - return render( - - - {ui} - - , - ); -} - describe("DraggableAllDayAgendaEvent", () => { const standaloneEvent = createMockStandaloneEvent({}, true); @@ -32,14 +18,34 @@ describe("DraggableAllDayAgendaEvent", () => { position: gridEventDefaultPosition, }; + const mockInteractions = { + getReferenceProps: jest.fn(() => ({})), + getFloatingProps: jest.fn(() => ({})), + getItemProps: jest.fn(() => ({})), + } as any; + it("renders the event title", () => { - renderWithMenuProvider(); + render( + , + ); expect(screen.getByText(event.title!)).toBeInTheDocument(); }); it("has the correct aria-label and data-event-id", () => { - renderWithMenuProvider(); + render( + , + ); const eventButton = screen.getByRole("button"); diff --git a/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent.tsx b/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent.tsx index dec907d4c..02b5f89cb 100644 --- a/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent.tsx +++ b/packages/web/src/views/Day/components/Agenda/Events/AllDayAgendaEvent/DraggableAllDayAgendaEvent.tsx @@ -1,45 +1,40 @@ import classNames from "classnames"; import fastDeepEqual from "fast-deep-equal/react"; -import { FocusEvent, MouseEvent, memo, useCallback } from "react"; +import { memo } from "react"; +import { UseInteractionsReturn } from "@floating-ui/react"; import { Categories_Event } from "@core/types/event.types"; import { CLASS_ALL_DAY_CALENDAR_EVENT } from "@web/common/constants/web.constants"; -import { CursorItem } from "@web/common/context/open-at-cursor"; -import { useOpenAtCursor } from "@web/common/hooks/useOpenAtCursor"; +import { useGridMaxZIndex } from "@web/common/hooks/useGridMaxZIndex"; import { Schema_GridEvent } from "@web/common/types/web.event.types"; import { Draggable } from "@web/components/DND/Draggable"; -import { useDraftContextV2 } from "@web/views/Calendar/components/Draft/context/useDraftContextV2"; import { AllDayAgendaEvent } from "@web/views/Day/components/Agenda/Events/AllDayAgendaEvent/AllDayAgendaEvent"; +import { useOpenAgendaEventPreview } from "@web/views/Day/hooks/events/useOpenAgendaEventPreview"; +import { useOpenEventContextMenu } from "@web/views/Day/hooks/events/useOpenEventContextMenu"; export const DraggableAllDayAgendaEvent = memo( - ({ event }: { event: Schema_GridEvent }) => { - const context = useDraftContextV2(); - const { nodeId, interactions, closeOpenAtCursor } = useOpenAtCursor(); - const { openAgendaEventPreview, openEventContextMenu } = context; - const preventBlur = nodeId && nodeId !== CursorItem.EventPreview; + ({ + event, + interactions, + isDraftEvent, + isNewDraftEvent, + }: { + event: Schema_GridEvent; + interactions: UseInteractionsReturn; + isDraftEvent: boolean; + isNewDraftEvent: boolean; + }) => { + const openAgendaEventPreview = useOpenAgendaEventPreview(); + const openEventContextMenu = useOpenEventContextMenu(); + const maxZIndex = useGridMaxZIndex(); if (!event.startDate || !event.endDate || !event.isAllDay) return null; - const blurEvent = useCallback( - (e: MouseEvent | FocusEvent) => { - e.preventDefault(); - e.stopPropagation(); - - if (preventBlur) return; - - closeOpenAtCursor(); - }, - [closeOpenAtCursor, preventBlur], - ); - return ( {}, // no-op, click already set on ID_GRID_ALLDAY_ROW - onContextMenu: openEventContextMenu, - onMouseEnter: openAgendaEventPreview, - onFocus: openAgendaEventPreview, - onMouseLeave: blurEvent, - onBlur: blurEvent, + {...interactions?.getReferenceProps({ + onContextMenu: isNewDraftEvent ? undefined : openEventContextMenu, + onFocus: isNewDraftEvent ? undefined : openAgendaEventPreview, + onPointerEnter: isNewDraftEvent ? undefined : openAgendaEventPreview, })} dndProps={{ id: event._id, @@ -56,10 +51,15 @@ export const DraggableAllDayAgendaEvent = memo( "focus-visible:ring-2", "focus:outline-none focus-visible:ring-yellow-200", )} + style={{ + zIndex: isDraftEvent ? maxZIndex + 3 : undefined, + }} title={event.title} tabIndex={0} role="button" aria-label={event.title || "Untitled event"} + data-draft-event={isDraftEvent} + data-new-draft-event={isNewDraftEvent} data-event-id={event._id} > diff --git a/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.test.tsx b/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.test.tsx index c7c1ca194..daea2898e 100644 --- a/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.test.tsx +++ b/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.test.tsx @@ -1,17 +1,12 @@ import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory"; -import { OpenAtCursorProvider } from "@web/common/context/open-at-cursor"; +import { render } from "@web/__tests__/__mocks__/mock.render"; import { Schema_GridEvent } from "@web/common/types/web.event.types"; import { gridEventDefaultPosition } from "@web/common/utils/event/event.util"; -import { useDraftContextV2 } from "@web/views/Calendar/components/Draft/context/useDraftContextV2"; import { DraggableTimedAgendaEvent } from "@web/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent"; -jest.mock("@web/views/Calendar/components/Draft/context/useDraftContextV2"); - -function renderWithMenuProvider(ui: React.ReactElement) { - return render({ui}); -} +jest.mock("@web/views/Calendar/components/Draft/context/useDraft"); describe("AgendaEvent", () => { const standaloneEvent = createMockStandaloneEvent(); @@ -26,11 +21,16 @@ describe("AgendaEvent", () => { position: gridEventDefaultPosition, }; - beforeEach(() => { - (useDraftContextV2 as jest.Mock).mockReturnValue({ - maxAgendaZIndex: 10, - }); - }); + const defaultProps = { + bounds: document.createElement("div"), + interactions: { + getReferenceProps: jest.fn(), + getFloatingProps: jest.fn(), + getItemProps: jest.fn(), + } as any, + isDraftEvent: false, + isNewDraftEvent: false, + }; it("should not render when startDate is missing", () => { const event: Partial = { @@ -38,10 +38,13 @@ describe("AgendaEvent", () => { startDate: undefined, }; - const { container } = renderWithMenuProvider( - , + render( + , ); - expect(container.firstChild).toBeNull(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); it("should not render when endDate is missing", () => { @@ -50,14 +53,17 @@ describe("AgendaEvent", () => { endDate: undefined, }; - const { container } = renderWithMenuProvider( - , + render( + , ); - expect(container.firstChild).toBeNull(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); it("has the correct aria-label and data-event-id", () => { - renderWithMenuProvider(); + render(); const eventButton = screen.getByRole("button"); diff --git a/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.tsx b/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.tsx index 93c875eb0..01d7f6d5a 100644 --- a/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.tsx +++ b/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent.tsx @@ -1,49 +1,48 @@ import classNames from "classnames"; import fastDeepEqual from "fast-deep-equal/react"; -import { FocusEvent, MouseEvent, memo, useCallback } from "react"; +import { memo } from "react"; +import { UseInteractionsReturn } from "@floating-ui/react"; import { Categories_Event } from "@core/types/event.types"; import { CLASS_TIMED_CALENDAR_EVENT } from "@web/common/constants/web.constants"; -import { CursorItem } from "@web/common/context/open-at-cursor"; -import { useOpenAtCursor } from "@web/common/hooks/useOpenAtCursor"; +import { useGridMaxZIndex } from "@web/common/hooks/useGridMaxZIndex"; import { Schema_GridEvent } from "@web/common/types/web.event.types"; import { Draggable } from "@web/components/DND/Draggable"; -import { useDraftContextV2 } from "@web/views/Calendar/components/Draft/context/useDraftContextV2"; +import { Resizable } from "@web/components/DND/Resizable"; import { TimedAgendaEvent } from "@web/views/Day/components/Agenda/Events/TimedAgendaEvent/TimedAgendaEvent"; +import { SLOT_HEIGHT } from "@web/views/Day/constants/day.constants"; +import { useOpenAgendaEventPreview } from "@web/views/Day/hooks/events/useOpenAgendaEventPreview"; +import { useOpenEventContextMenu } from "@web/views/Day/hooks/events/useOpenEventContextMenu"; import { getAgendaEventPosition } from "@web/views/Day/util/agenda/agenda.util"; export const DraggableTimedAgendaEvent = memo( - ({ event }: { event: Schema_GridEvent }) => { - const context = useDraftContextV2(); - const { nodeId, interactions, closeOpenAtCursor } = useOpenAtCursor(); - const { openAgendaEventPreview, openEventContextMenu } = context; - const preventBlur = nodeId && nodeId !== CursorItem.EventPreview; + ({ + event, + bounds, + interactions, + isDraftEvent, + isNewDraftEvent, + }: { + event: Schema_GridEvent; + bounds: HTMLElement; + interactions: UseInteractionsReturn; + isDraftEvent: boolean; + isNewDraftEvent: boolean; + }) => { + const openAgendaEventPreview = useOpenAgendaEventPreview(); + const openEventContextMenu = useOpenEventContextMenu(); + const maxZIndex = useGridMaxZIndex(); if (!event.startDate || !event.endDate || event.isAllDay) return null; const startDate = new Date(event.startDate); const startPosition = getAgendaEventPosition(startDate); - const blurEvent = useCallback( - (e: MouseEvent | FocusEvent) => { - e.preventDefault(); - e.stopPropagation(); - - if (preventBlur) return; - - closeOpenAtCursor(); - }, - [closeOpenAtCursor, nodeId], - ); - return ( {}, // no-op, click already set on ID_GRID_MAIN - onContextMenu: openEventContextMenu, - onMouseEnter: openAgendaEventPreview, - onFocus: openAgendaEventPreview, - onMouseLeave: blurEvent, - onBlur: blurEvent, + {...interactions?.getReferenceProps({ + onContextMenu: isNewDraftEvent ? undefined : openEventContextMenu, + onFocus: isNewDraftEvent ? undefined : openAgendaEventPreview, + onPointerEnter: isNewDraftEvent ? undefined : openAgendaEventPreview, })} dndProps={{ id: event._id, @@ -54,19 +53,43 @@ export const DraggableTimedAgendaEvent = memo( }, }} as="div" + asChild className={classNames( CLASS_TIMED_CALENDAR_EVENT, "absolute cursor-move touch-none rounded focus:outline-none", "focus-visible:rounded focus-visible:ring-2", "focus:outline-none focus-visible:ring-yellow-200", )} - style={{ top: `${startPosition}px` }} + style={{ + top: `${startPosition}px`, + zIndex: isDraftEvent ? maxZIndex + 3 : undefined, + }} tabIndex={0} role="button" + data-draft-event={isDraftEvent} + data-new-draft-event={isNewDraftEvent} data-event-id={event._id} aria-label={event.title || "Untitled event"} > - + + + ); }, diff --git a/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/TimedAgendaEvent.tsx b/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/TimedAgendaEvent.tsx index d520634b7..904d24573 100644 --- a/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/TimedAgendaEvent.tsx +++ b/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/TimedAgendaEvent.tsx @@ -1,6 +1,7 @@ import classNames from "classnames"; import fastDeepEqual from "fast-deep-equal/react"; import { memo } from "react"; +import { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"; import { Priorities } from "@core/constants/core.constants"; import { darken, isDark } from "@core/util/color.utils"; import { colorByPriority } from "@web/common/styles/theme.util"; @@ -11,9 +12,11 @@ export const TimedAgendaEvent = memo( ({ event, isDragging, + listeners, }: { event: Schema_GridEvent; isDragging?: boolean; + listeners?: SyntheticListenerMap; }) => { const startDate = new Date(event.startDate); const endDate = new Date(event.endDate); @@ -30,6 +33,7 @@ export const TimedAgendaEvent = memo( return (
{event.title || "Untitled"} diff --git a/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/TimedAgendaEvents.tsx b/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/TimedAgendaEvents.tsx index d98dfcaaa..984d7f02e 100644 --- a/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/TimedAgendaEvents.tsx +++ b/packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/TimedAgendaEvents.tsx @@ -1,49 +1,102 @@ import classNames from "classnames"; -import { memo, useState } from "react"; +import fastDeepEqual from "fast-deep-equal/react"; +import { Ref, forwardRef, memo, useEffect, useMemo, useState } from "react"; +import { useLocation } from "react-router-dom"; +import { UseInteractionsReturn, useMergeRefs } from "@floating-ui/react"; +import { StringV4Schema } from "@core/types/type.utils"; import { ID_GRID_MAIN } from "@web/common/constants/web.constants"; import { useGridOrganization } from "@web/common/hooks/useGridOrganization"; +import { Schema_GridEvent } from "@web/common/types/web.event.types"; +import { + CompassDOMEvents, + compassEventEmitter, +} from "@web/common/utils/dom/event-emitter.util"; import { Droppable } from "@web/components/DND/Droppable"; import { selectIsDayEventsProcessing, selectTimedDayEvents, } from "@web/ducks/events/selectors/event.selectors"; import { useAppSelector } from "@web/store/store.hooks"; -import { useDraftContextV2 } from "@web/views/Calendar/components/Draft/context/useDraftContextV2"; +import { useDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; import { AgendaSkeleton } from "@web/views/Day/components/Agenda/AgendaSkeleton/AgendaSkeleton"; import { DraggableTimedAgendaEvent } from "@web/views/Day/components/Agenda/Events/TimedAgendaEvent/DraggableTimedAgendaEvent"; +import { useOpenEventForm } from "@web/views/Forms/hooks/useOpenEventForm"; + +export const TimedAgendaEvents = memo( + forwardRef( + ( + { + height, + interactions, + }: { + height?: number; + interactions: UseInteractionsReturn; + }, + _ref: Ref, + ) => { + const { pathname } = useLocation(); + const timedEvents = useAppSelector(selectTimedDayEvents); + const isLoading = useAppSelector(selectIsDayEventsProcessing); + const draft = useDraft(); + const openEventForm = useOpenEventForm(); + const [ref, setRef] = useState(null); + const mergedRef = useMergeRefs([setRef, _ref]); + + const events = useMemo(() => { + if (!draft || !StringV4Schema.safeParse(draft._id).success) { + return timedEvents; + } + + const existing = timedEvents.find((event) => event._id === draft?._id); + + if (existing) return timedEvents; + + const allEvents = [draft, ...timedEvents]; + + return allEvents; + }, [timedEvents, draft]); + + useGridOrganization(ref); + + // Center the calendar around the current time when the view mounts + useEffect(() => { + compassEventEmitter.emit(CompassDOMEvents.SCROLL_TO_NOW_LINE); + }, [pathname]); -export const TimedAgendaEvents = memo(({ height }: { height?: number }) => { - const events = useAppSelector(selectTimedDayEvents); - const isLoading = useAppSelector(selectIsDayEventsProcessing); - const { openEventForm } = useDraftContextV2(); - const [ref, setRef] = useState(null); - - useGridOrganization(ref); - - return ( - openEventForm()} - > - {/* Event blocks */} - {isLoading || ref === null ? ( - - ) : ( - events.map((event) => ( - - )) - )} - - ); -}); + return ( + + {/* Event blocks */} + {isLoading || ref === null ? ( + + ) : ( + events.map((event) => ( + e._id === event._id)} + /> + )) + )} + + ); + }, + ), + fastDeepEqual, +); TimedAgendaEvents.displayName = "TimedAgendaEvents"; diff --git a/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.test.tsx b/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.test.tsx index f586183ca..e5b12c88b 100644 --- a/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.test.tsx +++ b/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.test.tsx @@ -12,14 +12,13 @@ jest.mock("@web/common/utils/dom/grid-organization.util", () => { const { BehaviorSubject } = require("rxjs"); return { maxAgendaZIndex$: new BehaviorSubject(10), + maxGridZIndex$: new BehaviorSubject(10), }; }); jest.mock("@web/views/Day/util/agenda/agenda.util"); jest.mock("@web/views/Day/util/time/time.util"); describe("NowLine", () => { - const mockNowLineRef = { current: null }; - beforeEach(() => { jest.clearAllMocks(); @@ -31,14 +30,14 @@ describe("NowLine", () => { }); it("renders correctly", () => { - render(); + render(); const nowLine = screen.getByText("10:00 AM"); expect(nowLine).toBeInTheDocument(); }); it("sets the correct position style", () => { - const { container } = render(); + const { container } = render(); // The main div has data-now-marker="true" const nowLineElement = container.querySelector('[data-now-marker="true"]'); @@ -46,7 +45,7 @@ describe("NowLine", () => { }); it("sets the correct z-index", () => { - const { container } = render(); + const { container } = render(); const nowLineElement = container.querySelector('[data-now-marker="true"]'); expect(nowLineElement).toHaveStyle({ zIndex: "10" }); @@ -59,7 +58,7 @@ describe("NowLine", () => { return jest.fn(); }); - render(); + render(); // Initial render expect(getNowLinePosition).toHaveBeenCalledTimes(1); @@ -79,7 +78,7 @@ describe("NowLine", () => { const cleanupMock = jest.fn(); (setupMinuteSync as jest.Mock).mockReturnValue(cleanupMock); - const { unmount } = render(); + const { unmount } = render(); unmount(); diff --git a/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.tsx b/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.tsx index 48c22d6c4..6bdb34995 100644 --- a/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.tsx +++ b/packages/web/src/views/Day/components/Agenda/NowLine/NowLine.tsx @@ -1,40 +1,64 @@ -import { ForwardedRef, forwardRef, useEffect, useState } from "react"; -import { maxAgendaZIndex$ } from "@web/common/utils/dom/grid-organization.util"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { fromEvent, share } from "rxjs"; +import { useGridMaxZIndex } from "@web/common/hooks/useGridMaxZIndex"; +import { + CompassDOMEvents, + compassEventEmitter, +} from "@web/common/utils/dom/event-emitter.util"; import { getAgendaEventTime, getNowLinePosition, } from "@web/views/Day/util/agenda/agenda.util"; import { setupMinuteSync } from "@web/views/Day/util/time/time.util"; -export const NowLine = forwardRef( - (_: {}, ref: ForwardedRef) => { - const [currentTime, setCurrentTime] = useState(new Date()); - - useEffect(() => { - const cleanup = setupMinuteSync(() => { - setCurrentTime(new Date()); - }); - - return cleanup; - }, []); - - return ( -
-
-
- {getAgendaEventTime(currentTime)} -
+const scroll$ = fromEvent( + compassEventEmitter, + CompassDOMEvents.SCROLL_TO_NOW_LINE, +).pipe(share()); + +export const NowLine = memo(function NowLine() { + const maxZIndex = useGridMaxZIndex(); + const ref = useRef(null); + const [currentTime, setCurrentTime] = useState(new Date()); + + const scrollToNow = useCallback(() => { + ref.current?.scrollIntoView({ + block: "center", + inline: "nearest", + behavior: "smooth", + }); + }, [ref]); + + useEffect(() => { + const cleanup = setupMinuteSync(() => { + setCurrentTime(new Date()); + }); + + return cleanup; + }, []); + + useEffect(() => { + const subscription = scroll$.subscribe(scrollToNow); + + return () => subscription.unsubscribe(); + }, [scrollToNow]); + + return ( +
+
+
+ {getAgendaEventTime(currentTime)}
- ); - }, -); +
+ ); +}); NowLine.displayName = "NowLine"; diff --git a/packages/web/src/views/Day/components/ContextMenu/EventContextMenu.test.tsx b/packages/web/src/views/Day/components/ContextMenu/EventContextMenu.test.tsx index 1e3aa5c16..86ca76a39 100644 --- a/packages/web/src/views/Day/components/ContextMenu/EventContextMenu.test.tsx +++ b/packages/web/src/views/Day/components/ContextMenu/EventContextMenu.test.tsx @@ -5,20 +5,32 @@ import { screen, waitFor } from "@testing-library/react"; import { Origin, Priorities } from "@core/constants/core.constants"; import { Schema_Event } from "@core/types/event.types"; import { createStoreWithEvents } from "@web/__tests__/utils/state/store.test.util"; +import { useFloatingAtCursor } from "@web/common/hooks/useFloatingAtCursor"; +import { setDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; import { TimedAgendaEvents } from "@web/views/Day/components/Agenda/Events/TimedAgendaEvent/TimedAgendaEvents"; import { EventContextMenu } from "@web/views/Day/components/ContextMenu/EventContextMenu"; +import { useAgendaInteractionsAtCursor } from "@web/views/Day/hooks/events/useAgendaInteractionsAtCursor"; import { renderWithDayProviders } from "@web/views/Day/util/day.test-util"; -const renderAgendaEvents = (events: Schema_Event[]) => { - const store = createStoreWithEvents(events); +const TestWrapper = () => { + const openChange = (open: boolean) => { + if (!open) setDraft(null); + }; + const floating = useFloatingAtCursor(openChange); + const interactions = useAgendaInteractionsAtCursor(floating); - const utils = renderWithDayProviders( + return ( <> - - - , - { store }, + + + ); +}; + +const renderAgendaEvents = (events: Schema_Event[]) => { + const store = createStoreWithEvents(events); + + const utils = renderWithDayProviders(, { store }); const dispatchSpy = jest.spyOn(store, "dispatch"); return { store, dispatchSpy, ...utils }; diff --git a/packages/web/src/views/Day/components/ContextMenu/EventContextMenu.tsx b/packages/web/src/views/Day/components/ContextMenu/EventContextMenu.tsx index 752c716c6..86e0a42d2 100644 --- a/packages/web/src/views/Day/components/ContextMenu/EventContextMenu.tsx +++ b/packages/web/src/views/Day/components/ContextMenu/EventContextMenu.tsx @@ -1,34 +1,50 @@ -import { FloatingPortal } from "@floating-ui/react"; -import { CursorItem } from "@web/common/context/open-at-cursor"; -import { useOpenAtCursor } from "@web/common/hooks/useOpenAtCursor"; -import { maxAgendaZIndex$ } from "@web/common/utils/dom/grid-organization.util"; -import { useDraftContextV2 } from "@web/views/Calendar/components/Draft/context/useDraftContextV2"; -import { BaseContextMenu } from "@web/views/Day/components/ContextMenu/BaseContextMenu"; +import classNames from "classnames"; +import { + FloatingPortal, + UseInteractionsReturn, + useFloating, +} from "@floating-ui/react"; +import { useGridMaxZIndex } from "@web/common/hooks/useGridMaxZIndex"; +import { + CursorItem, + useFloatingNodeIdAtCursor, +} from "@web/common/hooks/useOpenAtCursor"; +import { useDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; import { EventContextMenuItems } from "@web/views/Day/components/ContextMenu/EventContextMenuItems"; -export function EventContextMenu() { - const context = useDraftContextV2(); - const openAtCursor = useOpenAtCursor(); - const { draft, closeOpenAtCursor } = context; - const { nodeId, floating, interactions } = openAtCursor; +export function EventContextMenu({ + floating, + interactions, +}: { + floating: ReturnType; + interactions: UseInteractionsReturn; +}) { + const draft = useDraft(); + const nodeId = useFloatingNodeIdAtCursor(); + const floatingContextOpen = floating.context.open; + const maxZIndex = useGridMaxZIndex(); const isOpenAtCursor = nodeId === CursorItem.EventContextMenu; + const open = floatingContextOpen && isOpenAtCursor && !!draft; - if (!isOpenAtCursor || !draft) return null; + if (!open) return null; return ( - - + ); } diff --git a/packages/web/src/views/Day/components/ContextMenu/EventContextMenuItems.test.tsx b/packages/web/src/views/Day/components/ContextMenu/EventContextMenuItems.test.tsx index 295c2c950..2a377670d 100644 --- a/packages/web/src/views/Day/components/ContextMenu/EventContextMenuItems.test.tsx +++ b/packages/web/src/views/Day/components/ContextMenu/EventContextMenuItems.test.tsx @@ -7,11 +7,13 @@ import { RecurringEventUpdateScope, Schema_Event, } from "@core/types/event.types"; +import { closeFloatingAtCursor } from "@web/common/hooks/useOpenAtCursor"; import { deleteEventSlice } from "@web/ducks/events/slices/event.slice"; -import { useDraftContextV2 } from "@web/views/Calendar/components/Draft/context/useDraftContextV2"; +import { useDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; import { EventContextMenuItems } from "@web/views/Day/components/ContextMenu/EventContextMenuItems"; -jest.mock("@web/views/Calendar/components/Draft/context/useDraftContextV2"); +jest.mock("@web/views/Calendar/components/Draft/context/useDraft"); +jest.mock("@web/common/hooks/useOpenAtCursor"); const mockEvent: Schema_Event = { _id: "event-1", @@ -34,14 +36,12 @@ describe("EventContextMenuItems", () => { beforeEach(() => { jest.clearAllMocks(); + (closeFloatingAtCursor as jest.Mock).mockImplementation(mockClose); }); const renderWithProvider = (event: Schema_Event) => { const store = createMockStore(); - (useDraftContextV2 as jest.Mock).mockReturnValue({ - draft: event, - closeOpenAtCursor: mockClose, - }); + (useDraft as jest.Mock).mockReturnValue(event); return render( @@ -60,10 +60,7 @@ describe("EventContextMenuItems", () => { const store = createMockStore(); const dispatchSpy = jest.spyOn(store, "dispatch"); - (useDraftContextV2 as jest.Mock).mockReturnValue({ - draft: mockEvent, - closeOpenAtCursor: mockClose, - }); + (useDraft as jest.Mock).mockReturnValue(mockEvent); render( @@ -87,10 +84,7 @@ describe("EventContextMenuItems", () => { const store = createMockStore(); const dispatchSpy = jest.spyOn(store, "dispatch"); - (useDraftContextV2 as jest.Mock).mockReturnValue({ - draft: mockEvent, - closeOpenAtCursor: mockClose, - }); + (useDraft as jest.Mock).mockReturnValue(mockEvent); render( @@ -114,10 +108,7 @@ describe("EventContextMenuItems", () => { const store = createMockStore(); const dispatchSpy = jest.spyOn(store, "dispatch"); - (useDraftContextV2 as jest.Mock).mockReturnValue({ - draft: mockEvent, - closeOpenAtCursor: mockClose, - }); + (useDraft as jest.Mock).mockReturnValue(mockEvent); render( @@ -141,10 +132,7 @@ describe("EventContextMenuItems", () => { const eventWithoutId = { ...mockEvent, _id: undefined }; - (useDraftContextV2 as jest.Mock).mockReturnValue({ - draft: eventWithoutId, - closeOpenAtCursor: mockClose, - }); + (useDraft as jest.Mock).mockReturnValue(eventWithoutId); render( diff --git a/packages/web/src/views/Day/components/ContextMenu/EventContextMenuItems.tsx b/packages/web/src/views/Day/components/ContextMenu/EventContextMenuItems.tsx index 663579a82..a850385b0 100644 --- a/packages/web/src/views/Day/components/ContextMenu/EventContextMenuItems.tsx +++ b/packages/web/src/views/Day/components/ContextMenu/EventContextMenuItems.tsx @@ -1,13 +1,14 @@ import React, { useCallback } from "react"; -import { useDispatch } from "react-redux"; import { TrashIcon } from "@phosphor-icons/react"; import { RecurringEventUpdateScope } from "@core/types/event.types"; +import { closeFloatingAtCursor } from "@web/common/hooks/useOpenAtCursor"; import { deleteEventSlice } from "@web/ducks/events/slices/event.slice"; -import { useDraftContextV2 } from "@web/views/Calendar/components/Draft/context/useDraftContextV2"; +import { useAppDispatch } from "@web/store/store.hooks"; +import { useDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; export function EventContextMenuItems() { - const { draft, closeOpenAtCursor } = useDraftContextV2(); - const dispatch = useDispatch(); + const draft = useDraft(); + const dispatch = useAppDispatch(); const handleDelete = useCallback(() => { if (!draft?._id) return; @@ -20,8 +21,8 @@ export function EventContextMenuItems() { }), ); - closeOpenAtCursor(); - }, [dispatch, draft?._id, closeOpenAtCursor]); + closeFloatingAtCursor(); + }, [dispatch, draft?._id]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { diff --git a/packages/web/src/views/Day/hooks/events/useAgendaInteractionsAtCursor.test.ts b/packages/web/src/views/Day/hooks/events/useAgendaInteractionsAtCursor.test.ts new file mode 100644 index 000000000..d295b3742 --- /dev/null +++ b/packages/web/src/views/Day/hooks/events/useAgendaInteractionsAtCursor.test.ts @@ -0,0 +1,73 @@ +import { + useClick, + useDismiss, + useFocus, + useHover, + useInteractions, +} from "@floating-ui/react"; +import { renderHook } from "@testing-library/react"; +import { useAgendaInteractionsAtCursor } from "@web/views/Day/hooks/events/useAgendaInteractionsAtCursor"; + +jest.mock("@floating-ui/react", () => ({ + useClick: jest.fn(), + useDismiss: jest.fn(), + useFocus: jest.fn(), + useHover: jest.fn(), + useInteractions: jest.fn(), + safePolygon: jest.fn(() => jest.fn()), +})); + +describe("useAgendaInteractionsAtCursor", () => { + const mockFloating = { + context: {}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should call floating-ui hooks with correct params", () => { + const mockClick = {}; + const mockHover = {}; + const mockFocus = {}; + const mockDismiss = {}; + const mockInteractions = { getReferenceProps: jest.fn() }; + + (useClick as jest.Mock).mockReturnValue(mockClick); + (useHover as jest.Mock).mockReturnValue(mockHover); + (useFocus as jest.Mock).mockReturnValue(mockFocus); + (useDismiss as jest.Mock).mockReturnValue(mockDismiss); + (useInteractions as jest.Mock).mockReturnValue(mockInteractions); + + const { result } = renderHook(() => + useAgendaInteractionsAtCursor(mockFloating), + ); + + expect(useClick).toHaveBeenCalledWith(mockFloating.context, { + toggle: false, + stickIfOpen: true, + }); + + expect(useHover).toHaveBeenCalledWith(mockFloating.context, { + handleClose: expect.any(Function), // safePolygon result + }); + + expect(useFocus).toHaveBeenCalledWith(mockFloating.context, { + visibleOnly: true, + }); + + expect(useDismiss).toHaveBeenCalledWith(mockFloating.context, { + outsidePress: false, + }); + + expect(useInteractions).toHaveBeenCalledWith([ + mockClick, + mockFocus, + mockHover, + mockDismiss, + ]); + + expect(result.current).toBe(mockInteractions); + }); +}); diff --git a/packages/web/src/views/Day/hooks/events/useAgendaInteractionsAtCursor.ts b/packages/web/src/views/Day/hooks/events/useAgendaInteractionsAtCursor.ts new file mode 100644 index 000000000..b9cc1cf8f --- /dev/null +++ b/packages/web/src/views/Day/hooks/events/useAgendaInteractionsAtCursor.ts @@ -0,0 +1,30 @@ +import { + safePolygon, + useClick, + useDismiss, + useFloating, + useFocus, + useHover, + useInteractions, +} from "@floating-ui/react"; + +export function useAgendaInteractionsAtCursor( + floating: ReturnType, +) { + const click = useClick(floating.context, { + toggle: false, + stickIfOpen: true, + }); + + const hover = useHover(floating.context, { + handleClose: safePolygon({ blockPointerEvents: true, buffer: -Infinity }), + }); + + const focus = useFocus(floating.context, { visibleOnly: true }); + + const dismiss = useDismiss(floating.context, { outsidePress: false }); + + const interactions = useInteractions([click, focus, hover, dismiss]); + + return interactions; +} diff --git a/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.test.ts b/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.test.ts new file mode 100644 index 000000000..df153c991 --- /dev/null +++ b/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.test.ts @@ -0,0 +1,128 @@ +import { renderHook } from "@testing-library/react"; +import { DATA_EVENT_ELEMENT_ID } from "@web/common/constants/web.constants"; +import { + CursorItem, + openFloatingAtCursor, +} from "@web/common/hooks/useOpenAtCursor"; +import { selectEventById } from "@web/ducks/events/selectors/event.selectors"; +import { setDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; +import { useOpenAgendaEventPreview } from "@web/views/Day/hooks/events/useOpenAgendaEventPreview"; +import { getEventClass } from "@web/views/Day/util/agenda/focus.util"; + +jest.mock("@web/store", () => ({ + store: { + getState: jest.fn(), + }, +})); + +jest.mock("@web/common/hooks/useOpenAtCursor", () => ({ + CursorItem: { EventPreview: "event-preview" }, + openFloatingAtCursor: jest.fn(), +})); + +jest.mock("@web/ducks/events/selectors/event.selectors", () => ({ + selectEventById: jest.fn(), +})); + +jest.mock("@web/views/Calendar/components/Draft/context/useDraft", () => ({ + setDraft: jest.fn(), +})); + +jest.mock("@web/views/Day/util/agenda/focus.util", () => ({ + getEventClass: jest.fn(), +})); + +describe("useOpenAgendaEventPreview", () => { + const mockStore = jest.requireMock("@web/store").store; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should open event preview when event id and reference exist", () => { + const eventId = "123"; + const eventClass = "event-class"; + const mockEvent = { _id: eventId, title: "Test Event" }; + const mockReference = { + getAttribute: jest.fn().mockReturnValue(eventId), + }; + const mockElement = { + closest: jest.fn().mockReturnValue(mockReference), + }; + const mockEventObj = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + currentTarget: mockElement, + }; + + (getEventClass as jest.Mock).mockReturnValue(eventClass); + (selectEventById as jest.Mock).mockReturnValue(mockEvent); + + const { result } = renderHook(() => useOpenAgendaEventPreview()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result.current(mockEventObj as any); + + expect(mockEventObj.preventDefault).toHaveBeenCalled(); + expect(mockEventObj.stopPropagation).toHaveBeenCalled(); + expect(getEventClass).toHaveBeenCalledWith(mockElement); + expect(mockElement.closest).toHaveBeenCalledWith(`.${eventClass}`); + expect(mockReference.getAttribute).toHaveBeenCalledWith( + DATA_EVENT_ELEMENT_ID, + ); + expect(selectEventById).toHaveBeenCalledWith(mockStore.getState(), eventId); + expect(setDraft).toHaveBeenCalledWith(mockEvent); + expect(openFloatingAtCursor).toHaveBeenCalledWith({ + nodeId: CursorItem.EventPreview, + placement: "right", + reference: mockReference, + }); + }); + + it("should not open event preview if event id is missing", () => { + const eventClass = "event-class"; + const mockReference = { + getAttribute: jest.fn().mockReturnValue(null), + }; + const mockElement = { + closest: jest.fn().mockReturnValue(mockReference), + }; + const mockEventObj = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + currentTarget: mockElement, + }; + + (getEventClass as jest.Mock).mockReturnValue(eventClass); + + const { result } = renderHook(() => useOpenAgendaEventPreview()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result.current(mockEventObj as any); + + expect(setDraft).not.toHaveBeenCalled(); + expect(openFloatingAtCursor).not.toHaveBeenCalled(); + }); + + it("should not open event preview if reference is missing", () => { + const eventClass = "event-class"; + const mockElement = { + closest: jest.fn().mockReturnValue(null), + }; + const mockEventObj = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + currentTarget: mockElement, + }; + + (getEventClass as jest.Mock).mockReturnValue(eventClass); + + const { result } = renderHook(() => useOpenAgendaEventPreview()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result.current(mockEventObj as any); + + expect(setDraft).not.toHaveBeenCalled(); + expect(openFloatingAtCursor).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.ts b/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.ts index c0ef01a14..e516eedbc 100644 --- a/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.ts +++ b/packages/web/src/views/Day/hooks/events/useOpenAgendaEventPreview.ts @@ -1,25 +1,15 @@ -import { - Dispatch, - FocusEvent, - MouseEvent, - SetStateAction, - useCallback, -} from "react"; -import { Schema_Event } from "@core/types/event.types"; +import { FocusEvent, MouseEvent, useCallback } from "react"; import { DATA_EVENT_ELEMENT_ID } from "@web/common/constants/web.constants"; -import { CursorItem } from "@web/common/context/open-at-cursor"; -import { useOpenAtCursor } from "@web/common/hooks/useOpenAtCursor"; +import { + CursorItem, + openFloatingAtCursor, +} from "@web/common/hooks/useOpenAtCursor"; import { selectEventById } from "@web/ducks/events/selectors/event.selectors"; import { store } from "@web/store"; +import { setDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; import { getEventClass } from "@web/views/Day/util/agenda/focus.util"; -export function useOpenAgendaEventPreview({ - setDraft, -}: { - setDraft: Dispatch>; -}) { - const { setNodeId, setPlacement, setReference } = useOpenAtCursor(); - +export function useOpenAgendaEventPreview() { const openAgendaEventPreview = useCallback( (e: MouseEvent | FocusEvent) => { e.preventDefault(); @@ -27,19 +17,18 @@ export function useOpenAgendaEventPreview({ const element = e.currentTarget; const eventClass = getEventClass(element); - const event = element?.closest(`.${eventClass}`); - const eventId = event?.getAttribute(DATA_EVENT_ELEMENT_ID); + const reference = element?.closest(`.${eventClass}`); + const eventId = reference?.getAttribute(DATA_EVENT_ELEMENT_ID); + const nodeId = CursorItem.EventPreview; - if (!eventId) return; + if (!eventId || !reference) return; const draftEvent = selectEventById(store.getState(), eventId); - setPlacement("right-start"); - setReference(event); setDraft(draftEvent); - setNodeId(CursorItem.EventPreview); + openFloatingAtCursor({ nodeId, placement: "right", reference }); }, - [setPlacement, setReference, setDraft, setNodeId], + [], ); return openAgendaEventPreview; diff --git a/packages/web/src/views/Day/hooks/events/useOpenEventContextMenu.test.ts b/packages/web/src/views/Day/hooks/events/useOpenEventContextMenu.test.ts new file mode 100644 index 000000000..0cbb05cdd --- /dev/null +++ b/packages/web/src/views/Day/hooks/events/useOpenEventContextMenu.test.ts @@ -0,0 +1,132 @@ +import React from "react"; +import { Provider } from "react-redux"; +import { renderHook } from "@testing-library/react"; +import { DATA_EVENT_ELEMENT_ID } from "@web/common/constants/web.constants"; +import { + CursorItem, + openFloatingAtCursor, +} from "@web/common/hooks/useOpenAtCursor"; +import { selectEventById } from "@web/ducks/events/selectors/event.selectors"; +import { setDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; +import { useOpenEventContextMenu } from "@web/views/Day/hooks/events/useOpenEventContextMenu"; +import { getEventClass } from "@web/views/Day/util/agenda/focus.util"; + +jest.mock("@web/common/hooks/useOpenAtCursor", () => ({ + CursorItem: { EventContextMenu: "event-context-menu" }, + openFloatingAtCursor: jest.fn(), +})); + +jest.mock("@web/ducks/events/selectors/event.selectors", () => ({ + selectEventById: jest.fn(), +})); + +jest.mock("@web/views/Calendar/components/Draft/context/useDraft", () => ({ + setDraft: jest.fn(), +})); + +jest.mock("@web/views/Day/util/agenda/focus.util", () => ({ + getEventClass: jest.fn(), +})); + +describe("useOpenEventContextMenu", () => { + const mockState = { events: {} }; + const mockStore = { + getState: jest.fn(() => mockState), + subscribe: jest.fn(), + dispatch: jest.fn(), + }; + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(Provider, { store: mockStore as any, children }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should open event context menu when event id and reference exist", () => { + const eventId = "123"; + const eventClass = "event-class"; + const mockEvent = { _id: eventId, title: "Test Event" }; + const mockReference = { + getAttribute: jest.fn().mockReturnValue(eventId), + }; + const mockElement = { + closest: jest.fn().mockReturnValue(mockReference), + }; + const mockEventObj = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + currentTarget: mockElement, + }; + + (getEventClass as jest.Mock).mockReturnValue(eventClass); + (selectEventById as jest.Mock).mockReturnValue(mockEvent); + + const { result } = renderHook(() => useOpenEventContextMenu(), { wrapper }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result.current(mockEventObj as any); + + expect(mockEventObj.preventDefault).toHaveBeenCalled(); + expect(mockEventObj.stopPropagation).toHaveBeenCalled(); + expect(getEventClass).toHaveBeenCalledWith(mockElement); + expect(mockElement.closest).toHaveBeenCalledWith(`.${eventClass}`); + expect(mockReference.getAttribute).toHaveBeenCalledWith( + DATA_EVENT_ELEMENT_ID, + ); + expect(selectEventById).toHaveBeenCalledWith(mockState, eventId); + expect(setDraft).toHaveBeenCalledWith(mockEvent); + expect(openFloatingAtCursor).toHaveBeenCalledWith({ + nodeId: CursorItem.EventContextMenu, + placement: "bottom", + reference: mockReference, + }); + }); + + it("should not open event context menu if event id is missing", () => { + const eventClass = "event-class"; + const mockReference = { + getAttribute: jest.fn().mockReturnValue(null), + }; + const mockElement = { + closest: jest.fn().mockReturnValue(mockReference), + }; + const mockEventObj = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + currentTarget: mockElement, + }; + + (getEventClass as jest.Mock).mockReturnValue(eventClass); + + const { result } = renderHook(() => useOpenEventContextMenu(), { wrapper }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result.current(mockEventObj as any); + + expect(setDraft).not.toHaveBeenCalled(); + expect(openFloatingAtCursor).not.toHaveBeenCalled(); + }); + + it("should not open event context menu if reference is missing", () => { + const eventClass = "event-class"; + const mockElement = { + closest: jest.fn().mockReturnValue(null), + }; + const mockEventObj = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + currentTarget: mockElement, + }; + + (getEventClass as jest.Mock).mockReturnValue(eventClass); + + const { result } = renderHook(() => useOpenEventContextMenu(), { wrapper }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result.current(mockEventObj as any); + + expect(setDraft).not.toHaveBeenCalled(); + expect(openFloatingAtCursor).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/views/Day/hooks/events/useOpenEventContextMenu.ts b/packages/web/src/views/Day/hooks/events/useOpenEventContextMenu.ts index 6e0586e0a..6ed1300dc 100644 --- a/packages/web/src/views/Day/hooks/events/useOpenEventContextMenu.ts +++ b/packages/web/src/views/Day/hooks/events/useOpenEventContextMenu.ts @@ -1,25 +1,16 @@ -import { - Dispatch, - FocusEvent, - MouseEvent, - SetStateAction, - useCallback, -} from "react"; +import { FocusEvent, MouseEvent, useCallback } from "react"; import { useStore } from "react-redux"; -import { Schema_Event } from "@core/types/event.types"; import { DATA_EVENT_ELEMENT_ID } from "@web/common/constants/web.constants"; -import { CursorItem } from "@web/common/context/open-at-cursor"; -import { useOpenAtCursor } from "@web/common/hooks/useOpenAtCursor"; +import { + CursorItem, + openFloatingAtCursor, +} from "@web/common/hooks/useOpenAtCursor"; import { selectEventById } from "@web/ducks/events/selectors/event.selectors"; import { RootState } from "@web/store"; +import { setDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; import { getEventClass } from "@web/views/Day/util/agenda/focus.util"; -export function useOpenEventContextMenu({ - setDraft, -}: { - setDraft: Dispatch>; -}) { - const { setNodeId, setPlacement, setReference } = useOpenAtCursor(); +export function useOpenEventContextMenu() { const store = useStore(); const openEventContextMenu = useCallback( @@ -29,19 +20,18 @@ export function useOpenEventContextMenu({ const element = e.currentTarget; const eventClass = getEventClass(element); - const event = element?.closest(`.${eventClass}`); - const eventId = event?.getAttribute(DATA_EVENT_ELEMENT_ID); + const reference = element?.closest(`.${eventClass}`); + const eventId = reference?.getAttribute(DATA_EVENT_ELEMENT_ID); + const nodeId = CursorItem.EventContextMenu; - if (!eventId) return; + if (!eventId || !reference) return; const draftEvent = selectEventById(store.getState(), eventId); - setPlacement("bottom"); - setReference(event); setDraft(draftEvent); - setNodeId(CursorItem.EventContextMenu); + openFloatingAtCursor({ nodeId, placement: "bottom", reference }); }, - [setPlacement, setReference, setDraft, setNodeId, store], + [store], ); return openEventContextMenu; diff --git a/packages/web/src/views/Day/util/agenda/focus.util.ts b/packages/web/src/views/Day/util/agenda/focus.util.ts index d823a31e0..4d42c8804 100644 --- a/packages/web/src/views/Day/util/agenda/focus.util.ts +++ b/packages/web/src/views/Day/util/agenda/focus.util.ts @@ -46,21 +46,26 @@ export function getEventClass(element: Element | null) { } export function getFirstAgendaEvent(): HTMLElement | null { + const cursorElement = getElementAtCursor(); + const overMainGrid = isOverMainGrid(cursorElement); + const overAllDayRow = isOverAllDayRow(cursorElement); + const isOutsideGrid = !overMainGrid && !overAllDayRow; const allDaySelector = `.${CLASS_ALL_DAY_CALENDAR_EVENT}`; const allDayGrid = document.getElementById(ID_GRID_ALLDAY_ROW); const allDayEvent = allDayGrid?.querySelector(allDaySelector); const allDayEventId = allDayEvent?.getAttribute(DATA_EVENT_ELEMENT_ID); - if (allDayEventId && allDayEvent) return allDayEvent; + if (isOutsideGrid && allDayEventId && allDayEvent) return allDayEvent; + if (overAllDayRow && allDayEventId && allDayEvent) return allDayEvent; const mainGrid = document.getElementById(ID_GRID_MAIN); const timedEventSelector = `.${CLASS_TIMED_CALENDAR_EVENT}`; const timedEvent = mainGrid?.querySelector(timedEventSelector); const timedEventId = timedEvent?.getAttribute(DATA_EVENT_ELEMENT_ID); - if (timedEventId && timedEvent) return timedEvent; + if (overMainGrid && timedEventId && timedEvent) return timedEvent; - return null; + return allDayEvent ?? timedEvent ?? null; } export function focusFirstAgendaEvent(): void { diff --git a/packages/web/src/views/Day/util/day.test-util.tsx b/packages/web/src/views/Day/util/day.test-util.tsx index 63d3650c9..33e717bb2 100644 --- a/packages/web/src/views/Day/util/day.test-util.tsx +++ b/packages/web/src/views/Day/util/day.test-util.tsx @@ -6,10 +6,8 @@ import dayjs, { Dayjs } from "@core/util/date/dayjs"; import { render } from "@web/__tests__/__mocks__/mock.render"; import { ROOT_ROUTES } from "@web/common/constants/routes"; import { MousePositionProvider } from "@web/common/context/mouse-position"; -import { OpenAtCursorProvider } from "@web/common/context/open-at-cursor"; import { loadSpecificDayData, loadTodayData } from "@web/routers/loaders"; import { store as defaultStore } from "@web/store"; -import { DraftProviderV2 } from "@web/views/Calendar/components/Draft/context/DraftProviderV2"; import { DateNavigationProvider } from "@web/views/Day/context/DateNavigationContext"; import { StorageInfoModalProvider } from "@web/views/Day/context/StorageInfoModalContext"; import { TaskProvider } from "@web/views/Day/context/TaskContext"; @@ -17,15 +15,11 @@ import { TaskProvider } from "@web/views/Day/context/TaskContext"; export const TaskProviderWrapper = ({ children }: PropsWithChildren) => { return ( - - - - - {children} - - - - + + + {children} + + ); }; diff --git a/packages/web/src/views/Day/view/DayViewContent.tsx b/packages/web/src/views/Day/view/DayViewContent.tsx index 83f3062d9..544b254f7 100644 --- a/packages/web/src/views/Day/view/DayViewContent.tsx +++ b/packages/web/src/views/Day/view/DayViewContent.tsx @@ -1,26 +1,21 @@ -import { useCallback, useRef } from "react"; +import { memo, useCallback } from "react"; import dayjs from "@core/util/date/dayjs"; +import { MousePositionProvider } from "@web/common/context/mouse-position"; +import { useEventDNDActions } from "@web/common/hooks/useEventDNDActions"; import { - CLASS_TIMED_CALENDAR_EVENT, - ID_GRID_EVENTS_TIMED, -} from "@web/common/constants/web.constants"; + CompassDOMEvents, + compassEventEmitter, +} from "@web/common/utils/dom/event-emitter.util"; import { - MousePositionProvider, - isElementInViewport, -} from "@web/common/context/mouse-position"; -import { OpenAtCursorProvider } from "@web/common/context/open-at-cursor"; -import { useEventDNDActions } from "@web/common/hooks/useEventDNDActions"; + openEventFormCreateEvent, + openEventFormEditEvent, +} from "@web/common/utils/event/event.util"; import { getShortcuts } from "@web/common/utils/shortcut/data/shortcuts.data"; -import { FloatingEventForm } from "@web/components/FloatingEventForm/FloatingEventForm"; import { ShortcutsOverlay } from "@web/components/Shortcuts/ShortcutOverlay/ShortcutsOverlay"; import { Dedication } from "@web/views/Calendar/components/Dedication"; -import { DraftProviderV2 } from "@web/views/Calendar/components/Draft/context/DraftProviderV2"; -import { useDraftContextV2 } from "@web/views/Calendar/components/Draft/context/useDraftContextV2"; import { useRefetch } from "@web/views/Calendar/hooks/useRefetch"; import { StyledCalendar } from "@web/views/Calendar/styled"; import { Agenda } from "@web/views/Day/components/Agenda/Agenda"; -import { AgendaEventPreview } from "@web/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview"; -import { EventContextMenu } from "@web/views/Day/components/ContextMenu/EventContextMenu"; import { DayCmdPalette } from "@web/views/Day/components/DayCmdPalette"; import { Header } from "@web/views/Day/components/Header/Header"; import { StorageInfoModal } from "@web/views/Day/components/StorageInfoModal/StorageInfoModal"; @@ -31,18 +26,13 @@ import { useDateInView } from "@web/views/Day/hooks/navigation/useDateInView"; import { useDateNavigation } from "@web/views/Day/hooks/navigation/useDateNavigation"; import { useDayViewShortcuts } from "@web/views/Day/hooks/shortcuts/useDayViewShortcuts"; import { useTasks } from "@web/views/Day/hooks/tasks/useTasks"; -import { - focusElement, - focusFirstAgendaEvent, - getElementMidFocalPoint, - getFocusedEvent, -} from "@web/views/Day/util/agenda/focus.util"; +import { focusFirstAgendaEvent } from "@web/views/Day/util/agenda/focus.util"; import { focusOnAddTaskInput, focusOnFirstTask, } from "@web/views/Day/util/day.shortcut.util"; -const DayViewContentInner = () => { +const DayViewContentInner = memo(() => { useRefetch(); useEventDNDActions(); @@ -61,7 +51,6 @@ const DayViewContentInner = () => { const { isOpen: isModalOpen, closeModal } = useStorageInfoModal(); const dateInView = useDateInView(); const shortcuts = getShortcuts({ currentDate: dateInView }); - const agendaRef = useRef<{ scrollToNow: () => void } | null>(null); useDayEvents(dateInView); @@ -115,45 +104,12 @@ const DayViewContentInner = () => { const isViewingToday = dateInView.isSame(today, "day"); if (isViewingToday) { - agendaRef.current?.scrollToNow(); + compassEventEmitter.emit(CompassDOMEvents.SCROLL_TO_NOW_LINE); } else { navigateToToday(); } }, [dateInView, navigateToToday]); - const { openEventForm } = useDraftContextV2(); - - const onCreateEvent = useCallback(() => { - openEventForm(true); - }, [openEventForm]); - - const handleEditEvent = useCallback(() => { - const event = getFocusedEvent(); - - if (!event) return; - - const isTimedEvent = event.classList.contains(CLASS_TIMED_CALENDAR_EVENT); - - if (isTimedEvent) { - const timedSurface = document.getElementById(ID_GRID_EVENTS_TIMED); - const willScroll = !isElementInViewport(event); - - if (!willScroll) { - return openEventForm(false, getElementMidFocalPoint(event)); - } - - focusElement(event); - - return timedSurface?.addEventListener( - "scrollend", - () => openEventForm(false, getElementMidFocalPoint(event)), - { once: true }, - ); - } - - openEventForm(false, getElementMidFocalPoint(event)); - }, [openEventForm]); - useDayViewShortcuts({ onAddTask: focusOnAddTaskInput, onEditTask: handleEditTask, @@ -162,8 +118,8 @@ const DayViewContentInner = () => { onMigrateTask: migrateTask, onFocusTasks: focusOnFirstTask, onFocusAgenda: focusFirstAgendaEvent, - onCreateEvent: onCreateEvent, - onEditEvent: handleEditEvent, + onCreateEvent: openEventFormCreateEvent, + onEditEvent: openEventFormEditEvent, onNextDay: navigateToNextDay, onPrevDay: navigateToPreviousDay, onGoToToday: handleGoToToday, @@ -184,7 +140,7 @@ const DayViewContentInner = () => { > - +
@@ -198,22 +154,14 @@ const DayViewContentInner = () => { { title: "Global", shortcuts: shortcuts.globalShortcuts }, ]} /> - - - - ); -}; +}); export const DayViewContent = () => { return ( - - - - - + ); }; diff --git a/packages/web/src/views/Forms/EventForm/types.ts b/packages/web/src/views/Forms/EventForm/types.ts index d7ee16c22..f19259cd3 100644 --- a/packages/web/src/views/Forms/EventForm/types.ts +++ b/packages/web/src/views/Forms/EventForm/types.ts @@ -23,7 +23,9 @@ export interface FormProps { onSubmit: (event: Schema_Event | null) => void; onSubmitEventForm?: (event: Schema_Event) => void; priority?: Priority; - setEvent: Dispatch>; + setEvent: + | Dispatch> + | ((event: Schema_Event | null) => void); } type EventField = diff --git a/packages/web/src/views/Forms/hooks/useCloseEventForm.test.ts b/packages/web/src/views/Forms/hooks/useCloseEventForm.test.ts new file mode 100644 index 000000000..4372deaf4 --- /dev/null +++ b/packages/web/src/views/Forms/hooks/useCloseEventForm.test.ts @@ -0,0 +1,22 @@ +import { renderHook } from "@testing-library/react"; +import { closeFloatingAtCursor } from "@web/common/hooks/useOpenAtCursor"; +import { setDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; +import { useCloseEventForm } from "@web/views/Forms/hooks/useCloseEventForm"; + +jest.mock("@web/common/hooks/useOpenAtCursor"); +jest.mock("@web/views/Calendar/components/Draft/context/useDraft"); + +describe("useCloseEventForm", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should close floating at cursor and set draft to null", () => { + const { result } = renderHook(() => useCloseEventForm()); + + result.current(); + + expect(closeFloatingAtCursor).toHaveBeenCalled(); + expect(setDraft).toHaveBeenCalledWith(null); + }); +}); diff --git a/packages/web/src/views/Forms/hooks/useCloseEventForm.ts b/packages/web/src/views/Forms/hooks/useCloseEventForm.ts new file mode 100644 index 000000000..0ce7f8e52 --- /dev/null +++ b/packages/web/src/views/Forms/hooks/useCloseEventForm.ts @@ -0,0 +1,12 @@ +import { useCallback } from "react"; +import { closeFloatingAtCursor } from "@web/common/hooks/useOpenAtCursor"; +import { setDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; + +export function useCloseEventForm() { + const closeEventForm = useCallback(() => { + closeFloatingAtCursor(); + setDraft(null); + }, []); + + return closeEventForm; +} diff --git a/packages/web/src/views/Forms/hooks/useOpenEventForm.test.ts b/packages/web/src/views/Forms/hooks/useOpenEventForm.test.ts index 0065c6281..1aa07a8be 100644 --- a/packages/web/src/views/Forms/hooks/useOpenEventForm.test.ts +++ b/packages/web/src/views/Forms/hooks/useOpenEventForm.test.ts @@ -6,7 +6,10 @@ import { CLASS_TIMED_CALENDAR_EVENT, DATA_EVENT_ELEMENT_ID, } from "@web/common/constants/web.constants"; -import { useOpenAtCursor } from "@web/common/hooks/useOpenAtCursor"; +import { isElementInViewport } from "@web/common/context/mouse-position"; +import { openFloatingAtCursor } from "@web/common/hooks/useOpenAtCursor"; +import { getCalendarEventElementFromGrid } from "@web/common/utils/event/event.util"; +import { setDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; import { useOpenEventForm } from "@web/views/Forms/hooks/useOpenEventForm"; // Mocks @@ -17,9 +20,23 @@ jest.mock("@web/views/Day/hooks/navigation/useDateInView"); jest.mock("@web/views/Day/util/agenda/agenda.util"); jest.mock("@web/views/Day/util/agenda/focus.util"); jest.mock("@web/ducks/events/selectors/event.selectors"); -jest.mock("@web/common/hooks/useOpenAtCursor"); +jest.mock("@web/common/hooks/useOpenAtCursor", () => ({ + openFloatingAtCursor: jest.fn(), + CursorItem: { EventForm: "EventForm" }, +})); +jest.mock("@web/views/Calendar/components/Draft/context/useDraft"); +jest.mock("@web/common/utils/event/event.util", () => ({ + getCalendarEventElementFromGrid: jest.fn(), +})); describe("useOpenEventForm", () => { + beforeAll(() => { + (getCalendarEventElementFromGrid as jest.Mock).mockImplementation(() => { + return document.createElement("div"); + }); + (isElementInViewport as jest.Mock).mockReturnValue(true); + }); + const { getUserId } = jest.requireMock("@web/auth/auth.util"); const { @@ -52,20 +69,15 @@ describe("useOpenEventForm", () => { ); const mockSetDraft = jest.fn(); - const mockSetExisting = jest.fn(); const mockSetOpenAtMousePosition = jest.fn(); - const mockSetReference = jest.fn(); const mockDateInView = dayjs("2023-01-01T12:00:00Z"); beforeEach(() => { jest.clearAllMocks(); - (useOpenAtCursor as jest.Mock).mockReturnValue({ - setOpen: mockSetOpenAtMousePosition, - setNodeId: jest.fn(), - setPlacement: jest.fn(), - setReference: mockSetReference, - floating: { refs: { setReference: jest.fn() } }, - }); + (setDraft as jest.Mock).mockImplementation(mockSetDraft); + (openFloatingAtCursor as jest.Mock).mockImplementation( + mockSetOpenAtMousePosition, + ); useDateInView.mockReturnValue(mockDateInView); getUserId.mockResolvedValue("user-123"); toNearestFifteenMinutes.mockReturnValue(0); @@ -89,18 +101,18 @@ describe("useOpenEventForm", () => { isOverMainGrid.mockReturnValue(true); getEventTimeFromPosition.mockReturnValue(mockStartTime); - const { result } = renderHook(() => - useOpenEventForm({ - setDraft: mockSetDraft, - setExisting: mockSetExisting, - }), - ); + const { result } = renderHook(() => useOpenEventForm()); await act(async () => { - await result.current(); + result.current( + new CustomEvent("click", { + detail: { create: true }, + }) as unknown as React.PointerEvent, + ); + await Promise.resolve(); }); - expect(mockSetExisting).toHaveBeenCalledWith(false); + expect(getCalendarEventElementFromGrid).toHaveBeenCalled(); expect(mockSetDraft).toHaveBeenCalledWith( expect.objectContaining({ startDate: mockStartTime.toISOString(), @@ -111,24 +123,21 @@ describe("useOpenEventForm", () => { origin: Origin.COMPASS, }), ); - expect(mockSetOpenAtMousePosition).toHaveBeenCalledWith(true); + expect(openFloatingAtCursor).toHaveBeenCalled(); }); it("should open form for new all-day event when over all-day row", async () => { isOverAllDayRow.mockReturnValue(true); - const { result } = renderHook(() => - useOpenEventForm({ - setDraft: mockSetDraft, - setExisting: mockSetExisting, - }), - ); + const { result } = renderHook(() => useOpenEventForm()); await act(async () => { - await result.current(); + await result.current({ + detail: { create: true }, + nativeEvent: new Event("click"), + } as unknown as React.PointerEvent); }); - expect(mockSetExisting).toHaveBeenCalledWith(false); expect(mockSetDraft).toHaveBeenCalledWith( expect.objectContaining({ startDate: mockDateInView.startOf("day").format("YYYY-MM-DD"), @@ -155,19 +164,15 @@ describe("useOpenEventForm", () => { getEventClass.mockReturnValue(CLASS_TIMED_CALENDAR_EVENT); selectEventById.mockReturnValue(mockEvent); - const { result } = renderHook(() => - useOpenEventForm({ - setDraft: mockSetDraft, - setExisting: mockSetExisting, - }), - ); + const { result } = renderHook(() => useOpenEventForm()); await act(async () => { - await result.current(); + await result.current({ + detail: { create: false }, + nativeEvent: new Event("click"), + } as unknown as React.PointerEvent); }); - expect(mockSetExisting).toHaveBeenCalledWith(true); expect(mockSetDraft).toHaveBeenCalledWith(mockEvent); - expect(mockSetReference).toHaveBeenCalled(); }); }); diff --git a/packages/web/src/views/Forms/hooks/useOpenEventForm.ts b/packages/web/src/views/Forms/hooks/useOpenEventForm.ts index 5026ce72e..fe283bcc1 100644 --- a/packages/web/src/views/Forms/hooks/useOpenEventForm.ts +++ b/packages/web/src/views/Forms/hooks/useOpenEventForm.ts @@ -1,63 +1,71 @@ -import { Dispatch, SetStateAction, useCallback } from "react"; +import { ObjectId } from "bson"; +import { PointerEvent, useCallback } from "react"; import { Origin, Priorities } from "@core/constants/core.constants"; import { Schema_Event } from "@core/types/event.types"; import dayjs, { Dayjs } from "@core/util/date/dayjs"; import { getUserId } from "@web/auth/auth.util"; -import { DATA_EVENT_ELEMENT_ID } from "@web/common/constants/web.constants"; +import { + DATA_EVENT_ELEMENT_ID, + ID_GRID_EVENTS_TIMED, +} from "@web/common/constants/web.constants"; import { getCursorPosition, - getMousePointRef, + isElementInViewport, isOverAllDayRow, isOverMainGrid, isOverSidebar, isOverSomedayMonth, isOverSomedayWeek, } from "@web/common/context/mouse-position"; -import { CursorItem } from "@web/common/context/open-at-cursor"; -import { useOpenAtCursor } from "@web/common/hooks/useOpenAtCursor"; +import { + CursorItem, + openFloatingAtCursor, +} from "@web/common/hooks/useOpenAtCursor"; import { getElementAtPoint } from "@web/common/utils/dom/event-emitter.util"; +import { getCalendarEventElementFromGrid } from "@web/common/utils/event/event.util"; import { selectEventById } from "@web/ducks/events/selectors/event.selectors"; import { store } from "@web/store"; +import { setDraft } from "@web/views/Calendar/components/Draft/context/useDraft"; import { useDateInView } from "@web/views/Day/hooks/navigation/useDateInView"; import { getEventTimeFromPosition, toNearestFifteenMinutes, } from "@web/views/Day/util/agenda/agenda.util"; -import { getEventClass } from "@web/views/Day/util/agenda/focus.util"; +import { + focusElement, + getEventClass, +} from "@web/views/Day/util/agenda/focus.util"; const YMD = dayjs.DateFormat.YEAR_MONTH_DAY_FORMAT; -export function useOpenEventForm({ - setDraft, - setExisting, -}: { - setExisting: Dispatch>; - setDraft: Dispatch>; -}) { +export function useOpenEventForm() { const dateInView = useDateInView(); - const openAtCursor = useOpenAtCursor(); - const { setOpen, setNodeId, setPlacement, setReference } = openAtCursor; - const { refs } = openAtCursor.floating; const openEventForm = useCallback( - async (create = false, cursor = getCursorPosition()) => { + async ({ detail }: PointerEvent) => { + const defaultDetails = { id: undefined, create: false }; + const details = typeof detail === "object" ? detail : defaultDetails; + const create = details?.create ?? false; + const cursor = getCursorPosition(); const user = await getUserId(); if (!user) return; const active = document.activeElement; const element = getElementAtPoint(cursor); - const eventClass = getEventClass(element); - const activeClass = getEventClass(active); - const cursorEvent = element?.closest(`.${eventClass}`); - const event = cursorEvent ?? active?.closest(`.${activeClass}`); - const existingEventId = event?.getAttribute(DATA_EVENT_ELEMENT_ID); + const eventClass = `.${getEventClass(element)}`; + const activeClass = `.${getEventClass(active)}`; + const cursorEvent = element?.closest(eventClass); + const event = cursorEvent ?? active?.closest(activeClass); + const id = details?.id; + const existingEventId = id ?? event?.getAttribute(DATA_EVENT_ELEMENT_ID); + const draftId = new ObjectId().toString(); + const _id = create ? draftId : (existingEventId ?? draftId); let draftEvent: Schema_Event; if (existingEventId && !create) { draftEvent = selectEventById(store.getState(), existingEventId); - setExisting(true); } else { const now = dayjs(); @@ -95,6 +103,7 @@ export function useOpenEventForm({ } draftEvent = { + _id, title: "", description: "", startDate: isAllDay ? startTime.format(YMD) : startTime.toISOString(), @@ -105,27 +114,37 @@ export function useOpenEventForm({ priority: Priorities.UNASSIGNED, origin: Origin.COMPASS, }; - - setExisting(false); } - setPlacement("left-start"); - setReference(null); - refs.setReference(getMousePointRef(cursor)); - setDraft(draftEvent); - setNodeId(CursorItem.EventForm); - setOpen(true); + setDraft(draftEvent); // preview will now be available on calendar surface + + queueMicrotask(() => { + const reference = getCalendarEventElementFromGrid(_id); + + if (reference) { + const willScroll = !isElementInViewport(reference); + + if (willScroll) { + const timedSurface = document.getElementById(ID_GRID_EVENTS_TIMED); + + focusElement(reference as HTMLElement); + + return timedSurface?.addEventListener( + "scrollend", + () => + openFloatingAtCursor({ + reference, + nodeId: CursorItem.EventForm, + }), + { once: true }, + ); + } else { + openFloatingAtCursor({ reference, nodeId: CursorItem.EventForm }); + } + } + }); }, - [ - setPlacement, - setReference, - refs, - setDraft, - setNodeId, - setOpen, - setExisting, - dateInView, - ], + [dateInView], ); return openEventForm; diff --git a/packages/web/src/views/Forms/hooks/useSaveEventForm.test.ts b/packages/web/src/views/Forms/hooks/useSaveEventForm.test.ts index d8c62616f..bb7df7491 100644 --- a/packages/web/src/views/Forms/hooks/useSaveEventForm.test.ts +++ b/packages/web/src/views/Forms/hooks/useSaveEventForm.test.ts @@ -1,13 +1,18 @@ -import { renderHook } from "@testing-library/react"; import { RecurringEventUpdateScope } from "@core/types/event.types"; import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory"; +import { renderHook } from "@web/__tests__/__mocks__/mock.render"; +import { selectEventById } from "@web/ducks/events/selectors/event.selectors"; import { createEventSlice, editEventSlice, } from "@web/ducks/events/slices/event.slice"; +import { useCloseEventForm } from "@web/views/Forms/hooks/useCloseEventForm"; import { useSaveEventForm } from "@web/views/Forms/hooks/useSaveEventForm"; jest.mock("@web/store/store.hooks"); +jest.mock("@web/views/Forms/hooks/useCloseEventForm"); +jest.mock("@web/ducks/events/selectors/event.selectors"); + jest.mock("@web/ducks/events/slices/event.slice", () => ({ createEventSlice: { actions: { @@ -19,6 +24,18 @@ jest.mock("@web/ducks/events/slices/event.slice", () => ({ request: jest.fn(), }, }, + deleteEventSlice: { + actions: { + request: jest.fn(), + }, + }, + eventsEntitiesSlice: { + reducer: jest.fn(() => ({})), + }, + getCurrentMonthEventsSlice: { reducer: jest.fn(() => ({})) }, + getSomedayEventsSlice: { reducer: jest.fn(() => ({})) }, + getWeekEventsSlice: { reducer: jest.fn(() => ({})) }, + getDayEventsSlice: { reducer: jest.fn(() => ({})) }, })); describe("useSaveEventForm", () => { @@ -29,12 +46,12 @@ describe("useSaveEventForm", () => { beforeEach(() => { jest.clearAllMocks(); useAppDispatch.mockReturnValue(mockDispatch); + (useCloseEventForm as jest.Mock).mockReturnValue(mockCloseEventForm); }); it("should dispatch createEventSlice.actions.request when not existing", () => { - const { result } = renderHook(() => - useSaveEventForm({ existing: false, closeEventForm: mockCloseEventForm }), - ); + (selectEventById as jest.Mock).mockReturnValue(null); + const { result } = renderHook(() => useSaveEventForm()); const event = createMockStandaloneEvent(); @@ -48,9 +65,8 @@ describe("useSaveEventForm", () => { }); it("should dispatch editEventSlice.actions.request when existing", () => { - const { result } = renderHook(() => - useSaveEventForm({ existing: true, closeEventForm: mockCloseEventForm }), - ); + (selectEventById as jest.Mock).mockReturnValue({}); + const { result } = renderHook(() => useSaveEventForm()); const event = createMockStandaloneEvent(); @@ -66,9 +82,7 @@ describe("useSaveEventForm", () => { }); it("should close form without saving if draft is null", () => { - const { result } = renderHook(() => - useSaveEventForm({ existing: false, closeEventForm: mockCloseEventForm }), - ); + const { result } = renderHook(() => useSaveEventForm()); result.current(null); diff --git a/packages/web/src/views/Forms/hooks/useSaveEventForm.ts b/packages/web/src/views/Forms/hooks/useSaveEventForm.ts index 42b36b0e9..ebfda5d3b 100644 --- a/packages/web/src/views/Forms/hooks/useSaveEventForm.ts +++ b/packages/web/src/views/Forms/hooks/useSaveEventForm.ts @@ -5,21 +5,19 @@ import { Schema_Event, } from "@core/types/event.types"; import { Schema_GridEvent } from "@web/common/types/web.event.types"; +import { selectEventById } from "@web/ducks/events/selectors/event.selectors"; import { createEventSlice, editEventSlice, } from "@web/ducks/events/slices/event.slice"; +import { store } from "@web/store"; import { useAppDispatch } from "@web/store/store.hooks"; import { OnSubmitParser } from "@web/views/Calendar/components/Draft/hooks/actions/submit.parser"; +import { useCloseEventForm } from "@web/views/Forms/hooks/useCloseEventForm"; -export function useSaveEventForm({ - existing, - closeEventForm, -}: { - existing: boolean; - closeEventForm: () => void; -}) { +export function useSaveEventForm() { const dispatch = useAppDispatch(); + const closeEventForm = useCloseEventForm(); const onCreate = useCallback( (draft: Schema_GridEvent) => { @@ -59,6 +57,10 @@ export function useSaveEventForm({ ) => { if (!draft) return closeEventForm(); + const existing = draft._id + ? !!selectEventById(store.getState(), draft._id) + : false; + if (existing) { onEdit(draft as Schema_GridEvent, applyTo); } else { @@ -67,7 +69,7 @@ export function useSaveEventForm({ closeEventForm(); }, - [existing, onCreate, onEdit, closeEventForm], + [closeEventForm, onEdit, onCreate], ); return saveEventForm; diff --git a/yarn.lock b/yarn.lock index 1ba8df1ad..8762d7819 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9930,6 +9930,11 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +re-resizable@^6.11.2: + version "6.11.2" + resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.11.2.tgz#2e8f7119ca3881d5b5aea0ffa014a80e5c1252b3" + integrity sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A== + react-cmdk@^1.3.9: version "1.3.9" resolved "https://registry.yarnpkg.com/react-cmdk/-/react-cmdk-1.3.9.tgz#77123f5120a47e35a517a8176550e96731667654"