From acd03721f39095632f37409b3b3e2c65db76ac82 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:35:30 +0400 Subject: [PATCH] feat(scheduler): support appointmentDragging options in new appointments --- .../__tests__/appointments_dragging.test.ts | 394 ++++++++++++++++++ .../scheduler/appointment_drag_controller.ts | 235 ++++++++++- .../js/__internal/scheduler/scheduler.ts | 11 + .../scheduler/workspaces/work_space.ts | 6 +- 4 files changed, 622 insertions(+), 24 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_dragging.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_dragging.test.ts index 36f9a02fe13c..2578b2db6de9 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_dragging.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_dragging.test.ts @@ -5,6 +5,7 @@ import fx from '@js/common/core/animation/fx'; import $ from '@js/core/renderer'; import type { Properties } from '@js/ui/scheduler'; import eventsEngine from '@ts/events/core/m_events_engine'; +import Draggable from '@ts/m_draggable'; import { createScheduler as baseCreateScheduler } from './__mock__/create_scheduler'; import { setupSchedulerTestEnvironment } from './__mock__/mock_scheduler'; @@ -946,4 +947,397 @@ describe('Appointments Dragging', () => { ); }); }); + + describe('appointmentDragging options', () => { + const appointmentData = { + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }; + + const createDraggingScheduler = ( + appointmentDragging: Record, + ) => createScheduler({ + dataSource: [{ ...appointmentData }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + appointmentDragging, + } as unknown as Properties); + + describe('callbacks', () => { + it('should call onDragStart with the raw appointment as itemData', async () => { + const onDragStart = jest.fn(); + const { POM, scheduler } = await createDraggingScheduler({ onDragStart }); + + const appointment = POM.getAppointments()[0].element; + dragStart(POM, appointment); + + expect(onDragStart).toHaveBeenCalledTimes(1); + expect(onDragStart).toHaveBeenCalledWith( + expect.objectContaining({ + component: scheduler, + itemData: expect.objectContaining(appointmentData), + }), + ); + }); + + it('should call onDragMove', async () => { + const onDragMove = jest.fn(); + const { POM } = await createDraggingScheduler({ onDragMove }); + + const appointment = POM.getAppointments()[0].element; + dragStart(POM, appointment); + dragMove(POM, appointment, POM.getDateTableCell(4, 0)); + + expect(onDragMove).toHaveBeenCalledTimes(1); + }); + + it('should call onDragEnd with drop-adjusted toItemData', async () => { + const captured: { itemData?: object; toItemData?: object } = {}; + const onDragEnd = jest.fn((e: { itemData: object; toItemData: object }) => { + captured.itemData = { ...e.itemData }; + captured.toItemData = { ...e.toItemData }; + }); + const { POM } = await createDraggingScheduler({ onDragEnd }); + + const appointment = POM.getAppointments()[0].element; + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, appointment); + dragMove(POM, appointment, targetCell); + dragEnd(POM, appointment); + + expect(onDragEnd).toHaveBeenCalledTimes(1); + expect(captured.itemData).toEqual(expect.objectContaining(appointmentData)); + expect(captured.toItemData).toEqual(expect.objectContaining({ + startDate: new Date(2015, 1, 9, 2), + endDate: new Date(2015, 1, 9, 3), + })); + }); + }); + + describe('toItemData', () => { + it('should provide toItemData on a same-component drop even without a highlighted cell', async () => { + const captured: { toItemData?: unknown } = {}; + const onDragEnd = jest.fn((e: { toItemData: unknown }) => { + captured.toItemData = e.toItemData; + }); + const { POM } = await createDraggingScheduler({ onDragEnd }); + + const appointment = POM.getAppointments()[0].element; + dragStart(POM, appointment); + dragEnd(POM, appointment); + + expect(onDragEnd).toHaveBeenCalledTimes(1); + expect(captured.toItemData).toBeDefined(); + }); + }); + + describe('cancellation', () => { + it('should not start dragging when onDragStart cancels', async () => { + const { POM } = await createDraggingScheduler({ + onDragStart: (e: { cancel: boolean }) => { e.cancel = true; }, + }); + + const appointment = POM.getAppointments()[0]; + dragStart(POM, appointment.element); + + expect(POM.getDraggedAppointment()).toBeNull(); + expect(appointment.isDragSource()).toBe(false); + }); + + it('should not save appointment when onDragEnd cancels', async () => { + const onAppointmentUpdating = jest.fn(); + const { POM } = await createScheduler({ + dataSource: [{ ...appointmentData }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + onAppointmentUpdating, + appointmentDragging: { + onDragEnd: (e: { cancel: boolean }) => { e.cancel = true; }, + }, + } as unknown as Properties); + + const appointment = POM.getAppointments()[0].element; + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, appointment); + dragMove(POM, appointment, targetCell); + dragEnd(POM, appointment); + + expect(onAppointmentUpdating).not.toHaveBeenCalled(); + }); + + it('should not highlight cell when onDragMove cancels', async () => { + const { POM } = await createDraggingScheduler({ + onDragMove: (e: { cancel: boolean }) => { e.cancel = true; }, + }); + + const appointment = POM.getAppointments()[0].element; + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, appointment); + dragMove(POM, appointment, targetCell); + + expect(targetCell.classList.contains('dx-scheduler-date-table-droppable-cell')).toBe(false); + }); + }); + + describe('pass-through options', () => { + it('should pass draggable options to the workspace draggable', async () => { + const data = { role: 'external' }; + const { POM } = await createDraggingScheduler({ + autoScroll: false, + scrollSpeed: 30, + scrollSensitivity: 20, + group: 'appointmentsGroup', + data, + }); + + const draggable = Draggable.getInstance(POM.getWorkspace()); + + expect(draggable.option('autoScroll')).toBe(false); + expect(draggable.option('scrollSpeed')).toBe(30); + expect(draggable.option('scrollSensitivity')).toBe(20); + expect(draggable.option('group')).toBe('appointmentsGroup'); + expect(draggable.option('data')).toEqual(data); + }); + + it('should keep draggable defaults when appointmentDragging is not configured', async () => { + const { POM } = await createScheduler({ + dataSource: [{ ...appointmentData }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + } as unknown as Properties); + + const draggable = Draggable.getInstance(POM.getWorkspace()); + + expect(draggable.option('autoScroll')).toBe(true); + expect(draggable.option('scrollSpeed')).toBe(30); + expect(draggable.option('scrollSensitivity')).toBe(60); + }); + }); + + describe('drag between components', () => { + it('should call onRemove when dropped on another component', async () => { + const onRemove = jest.fn(); + const { POM, scheduler } = await createDraggingScheduler({ onRemove }); + + const appointment = POM.getAppointments()[0].element; + dragStart(POM, appointment); + + const draggable = Draggable.getInstance(POM.getWorkspace()); + draggable.option('onDragEnd')({ + itemElement: appointment, + itemData: { ...appointmentData }, + fromComponent: scheduler, + toComponent: {}, + }); + + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('should call onAdd with the drop-cell time after the source finishes dragging', async () => { + const onAdd = jest.fn(); + const { POM, scheduler } = await createDraggingScheduler({ onAdd }); + + const appointment = POM.getAppointments()[0].element; + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, appointment); + dragMove(POM, appointment, targetCell); + + const draggable = Draggable.getInstance(POM.getWorkspace()); + draggable.option('onDragEnd')({ + itemElement: appointment, + itemData: { ...appointmentData }, + fromComponent: scheduler, + toComponent: {}, + }); + document.elementsFromPoint = jest.fn(() => [targetCell]); + draggable.option('onDrop')({ + itemElement: appointment, + itemData: { ...appointmentData }, + fromComponent: {}, + toComponent: scheduler, + event: { clientX: 10, clientY: 20 }, + }); + + expect(onAdd).toHaveBeenCalledTimes(1); + expect(onAdd).toHaveBeenCalledWith( + expect.objectContaining({ + itemData: expect.objectContaining({ + startDate: new Date(2015, 1, 9, 2), + endDate: new Date(2015, 1, 9, 3), + }), + }), + ); + }); + + it('should highlight the cell an external drag enters and drop onto it', async () => { + const onAdd = jest.fn(); + const { POM, scheduler } = await createDraggingScheduler({ onAdd }); + + const targetCell = POM.getDateTableCell(4, 0); + + eventsEngine.trigger(targetCell, { type: 'dxdragenter', target: targetCell }); + + expect(targetCell.classList.contains('dx-scheduler-date-table-droppable-cell')).toBe(true); + + const draggable = Draggable.getInstance(POM.getWorkspace()); + draggable.option('onDrop')({ + itemElement: document.createElement('div'), + itemData: { + text: 'External', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }, + fromComponent: {}, + toComponent: scheduler, + }); + + expect(onAdd).toHaveBeenCalledWith( + expect.objectContaining({ + itemData: expect.objectContaining({ + startDate: new Date(2015, 1, 9, 2), + endDate: new Date(2015, 1, 9, 3), + }), + }), + ); + expect(targetCell.classList.contains('dx-scheduler-date-table-droppable-cell')).toBe(false); + }); + + it('should still highlight external drags after a cancelled drag start', async () => { + const { POM } = await createDraggingScheduler({ + onDragStart: (e: { cancel: boolean }) => { e.cancel = true; }, + }); + + const appointment = POM.getAppointments()[0].element; + dragStart(POM, appointment); + + const targetCell = POM.getDateTableCell(4, 0); + eventsEngine.trigger(targetCell, { type: 'dxdragenter', target: targetCell }); + + expect(targetCell.classList.contains('dx-scheduler-date-table-droppable-cell')).toBe(true); + }); + + it('should resolve the drop cell from the drop coordinates', async () => { + const onAdd = jest.fn(); + const { POM, scheduler } = await createDraggingScheduler({ onAdd }); + + const targetCell = POM.getDateTableCell(4, 0); + document.elementsFromPoint = jest.fn(() => [targetCell]); + + const draggable = Draggable.getInstance(POM.getWorkspace()); + draggable.option('onDrop')({ + itemElement: document.createElement('div'), + itemData: { + text: 'External', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }, + fromComponent: {}, + toComponent: scheduler, + event: { clientX: 10, clientY: 20 }, + }); + + expect(onAdd).toHaveBeenCalledWith( + expect.objectContaining({ + itemData: expect.objectContaining({ + startDate: new Date(2015, 1, 9, 2), + endDate: new Date(2015, 1, 9, 3), + }), + }), + ); + }); + + it('should not move the appointment when dropped on another scheduler without a group', async () => { + const onAppointmentUpdating = jest.fn(); + const commonConfig = { + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + }; + const { POM: sourcePOM } = await createScheduler({ + ...commonConfig, + dataSource: [{ + text: 'Source', startDate: new Date(2015, 1, 9, 8), endDate: new Date(2015, 1, 9, 9), + }], + onAppointmentUpdating, + } as unknown as Properties); + const { POM: targetPOM } = await createScheduler({ + ...commonConfig, + dataSource: [{ + text: 'Target', + startDate: new Date(2015, 1, 9, 10), + endDate: new Date(2015, 1, 9, 11), + }], + } as unknown as Properties); + + const sourceAppointment = sourcePOM.getAppointments()[0].element; + const targetCell = targetPOM.getDateTableCell(4, 0); + + dragStart(sourcePOM, sourceAppointment); + dragMove(sourcePOM, sourceAppointment, targetCell); + dragEnd(sourcePOM, sourceAppointment); + + expect(onAppointmentUpdating).not.toHaveBeenCalled(); + }); + + it('should populate toItemData on a cross-component drag end', async () => { + const captured: { toItemData?: unknown } = {}; + const onDragEnd = jest.fn((e: { toItemData: unknown }) => { + captured.toItemData = e.toItemData; + }); + const { POM, scheduler } = await createDraggingScheduler({ + onDragEnd, + onRemove: jest.fn(), + }); + + const appointment = POM.getAppointments()[0].element; + dragStart(POM, appointment); + + const draggable = Draggable.getInstance(POM.getWorkspace()); + draggable.option('onDragEnd')({ + itemElement: appointment, + itemData: { ...appointmentData }, + fromComponent: scheduler, + toComponent: {}, + }); + + expect(captured.toItemData).toBeDefined(); + }); + + it('should not save the appointment into the source scheduler on cross-component drop', async () => { + const onAppointmentUpdating = jest.fn(); + const { POM, scheduler } = await createScheduler({ + dataSource: [{ ...appointmentData }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + onAppointmentUpdating, + appointmentDragging: { onRemove: jest.fn() }, + } as unknown as Properties); + + const appointment = POM.getAppointments()[0].element; + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, appointment); + dragMove(POM, appointment, targetCell); + + const draggable = Draggable.getInstance(POM.getWorkspace()); + draggable.option('onDragEnd')({ + itemElement: appointment, + itemData: { ...appointmentData }, + fromComponent: scheduler, + toComponent: {}, + }); + + expect(onAppointmentUpdating).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts b/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts index b6dfc46198b9..d563005d8ff7 100644 --- a/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts @@ -1,9 +1,22 @@ +import eventsEngine from '@js/common/core/events/core/events_engine'; +import { enter as dragEventEnter, leave as dragEventLeave } from '@js/common/core/events/drag'; +import { addNamespace } from '@js/common/core/events/utils/index'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; +import { extend } from '@js/core/utils/extend'; +import { getWindow } from '@js/core/utils/window'; import type { DragEndEvent, DragMoveEvent, DragStartEvent, DragTemplateData, Properties as DraggableProperties, } from '@js/ui/draggable'; -import type { Appointment } from '@js/ui/scheduler'; +import type { + Appointment, + AppointmentDraggingAddEvent, + AppointmentDraggingEndEvent, + AppointmentDraggingMoveEvent, + AppointmentDraggingRemoveEvent, + AppointmentDraggingStartEvent, + Properties as SchedulerProperties, +} from '@js/ui/scheduler'; import { getHeight, getWidth } from '@ts/core/utils/m_size'; import Draggable from '@ts/m_draggable'; @@ -15,12 +28,25 @@ import type { AppointmentItemViewModel } from './view_model/types'; const APPOINTMENT_DRAG_SOURCE_CLASS = 'dx-scheduler-appointment-drag-source'; const TOOLTIP_LIST_ITEM_CLASS = 'dx-list-item'; const HIGHLIGHTED_CELL_CLASS = 'dx-scheduler-date-table-droppable-cell'; +const CELL_SELECTOR = '.dx-scheduler-date-table-cell, .dx-scheduler-all-day-table-cell'; + +const DRAG_ENTER_EVENT = addNamespace(dragEventEnter, 'dxSchedulerAppointmentDrag'); +const DRAG_LEAVE_EVENT = addNamespace(dragEventLeave, 'dxSchedulerAppointmentDrag'); + +type AppointmentDraggingConfig = NonNullable; + +interface DraggedItemData { + appointmentData: Appointment; + targetedAppointmentData: TargetedAppointment; +} export interface AppointmentDragControllerOptions { component: Scheduler; $draggableContainer: () => dxElementWrapper; canDragAppointment: (appointmentData: Appointment) => boolean; getCellFromDragTarget: ($dragTarget: dxElementWrapper) => dxElementWrapper | null; + getCellFromPoint: (x: number, y: number) => dxElementWrapper | null; + getDroppableCell: () => dxElementWrapper; createComponent: ( $element: dxElementWrapper, @@ -29,6 +55,12 @@ export interface AppointmentDragControllerOptions { ) => T; hideAppointmentTooltip: () => void; + getAppointmentDraggingConfig: () => AppointmentDraggingConfig; + getUpdatedItemData: ( + appointmentData: Appointment, + $cell?: dxElementWrapper, + ) => Appointment; + updateAppointmentOnDrop: ( appointmentData: Appointment, targetedAppointmentData: TargetedAppointment, @@ -58,6 +90,10 @@ export class AppointmentDragController { private $dragClone: dxElementWrapper | null = null; + private $workSpace: dxElementWrapper | null = null; + + private draggedItemData: DraggedItemData | null = null; + constructor( private readonly options: AppointmentDragControllerOptions, ) { } @@ -78,20 +114,44 @@ export class AppointmentDragController { return this.$dragClone; }, onDragStart: (e: DragStartEvent) => { - e.itemData = draggableOptions.getAppointmentData($(e.itemElement)); - - this.onDragStart(e); + this.onDragStart(e, draggableOptions.getAppointmentData($(e.itemElement))); }, }; this.workSpaceDraggable = this.options.createComponent($workSpace, Draggable, config); + + this.$workSpace = $workSpace; + eventsEngine.on($workSpace, DRAG_ENTER_EVENT, CELL_SELECTOR, this.onExternalDragEnter); + eventsEngine.on($workSpace, DRAG_LEAVE_EVENT, this.onExternalDragLeave); } public disposeWorkSpaceDraggable(): void { + if (this.$workSpace) { + eventsEngine.off(this.$workSpace, DRAG_ENTER_EVENT); + eventsEngine.off(this.$workSpace, DRAG_LEAVE_EVENT); + this.$workSpace = null; + } + this.workSpaceDraggable?.dispose(); this.workSpaceDraggable = null; } + private readonly onExternalDragEnter = (e: { target: Element }): void => { + if (this.draggedItemData) { + return; + } + + this.highlightCell($(e.target).closest(CELL_SELECTOR)); + }; + + private readonly onExternalDragLeave = (): void => { + if (this.draggedItemData) { + return; + } + + this.removeCellHighlight(); + }; + public createTooltipDraggable( $tooltipList: dxElementWrapper, draggableOptions: TooltipDraggableOptions, @@ -116,12 +176,13 @@ export class AppointmentDragController { }, onDragStart: (e: DragStartEvent) => { tooltipItem = $(e.itemElement).data('dxListItemData') as unknown as AppointmentTooltipItem; - e.itemData = { - appointmentData: tooltipItem.appointment, - targetedAppointmentData: tooltipItem.targetedAppointment ?? tooltipItem.appointment, - }; - this.onDragStart(e); + this.onDragStart(e, { + appointmentData: tooltipItem.appointment, + targetedAppointmentData: ( + tooltipItem.targetedAppointment ?? tooltipItem.appointment + ) as TargetedAppointment, + }); }, // @ts-expect-error private option cursorOffset: () => ({ @@ -139,7 +200,9 @@ export class AppointmentDragController { } private getCommonDraggableConfig(): DraggableProperties { - return { + const config = this.options.getAppointmentDraggingConfig(); + + const draggableConfig: DraggableProperties = { // @ts-expect-error private option component: this.options.component, container: this.options.$draggableContainer().get(0), @@ -147,15 +210,35 @@ export class AppointmentDragController { onDragMove: this.onDragMove.bind(this), onDragEnd: this.onDragEnd.bind(this), onDragCancel: this.onDragCancel.bind(this), + onDrop: this.onDrop.bind(this), }; + + return extend(draggableConfig, { + autoScroll: config.autoScroll, + data: config.data, + group: config.group, + scrollSpeed: config.scrollSpeed, + scrollSensitivity: config.scrollSensitivity, + }) as DraggableProperties; } - private onDragStart(e: DragStartEvent): void { - if (!this.options.canDragAppointment(e.itemData.appointmentData)) { + private onDragStart(e: DragStartEvent, draggedItemData: DraggedItemData): void { + e.itemData = draggedItemData.appointmentData; + + if (!this.options.canDragAppointment(draggedItemData.appointmentData)) { e.cancel = true; return; } + this.options.getAppointmentDraggingConfig() + .onDragStart?.(e as unknown as AppointmentDraggingStartEvent); + + if (e.cancel) { + return; + } + + this.draggedItemData = draggedItemData; + this.$initialCell = this.options.getCellFromDragTarget($(e.itemElement)); this.options.hideAppointmentTooltip(); @@ -163,12 +246,18 @@ export class AppointmentDragController { $(e.itemElement).addClass(APPOINTMENT_DRAG_SOURCE_CLASS); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars private onDragMove(e: DragMoveEvent): void { + this.options.getAppointmentDraggingConfig() + .onDragMove?.(e as unknown as AppointmentDraggingMoveEvent); + + if (e.cancel) { + return; + } + const $cell = this.options.getCellFromDragTarget($(this.$dragClone)); if (!$cell) { - this.removeCellHighlight(); + this.removeHighlightedCell(); return; } @@ -176,23 +265,52 @@ export class AppointmentDragController { } private onDragEnd(e: DragEndEvent): void { - if (!this.$highlightedCell) { + const { draggedItemData } = this; + this.draggedItemData = null; + + if (!draggedItemData) { this.removeDraggingClasses($(e.itemElement)); return; } - const isSameCell = this.$initialCell?.is(this.$highlightedCell) ?? false; - const isSameScheduler = this.$highlightedCell.closest(e.fromComponent.$element()).length > 0; + if (this.$initialCell && !this.$initialCell.get(0)?.isConnected) { + this.$initialCell = null; + } + + const isSameComponent = e.fromComponent === e.toComponent; + const $dropCell = this.getSourceDropCell(e); + + (e as { toItemData?: unknown }).toItemData = { + ...e.itemData, + ...this.options.getUpdatedItemData(e.itemData, $dropCell ?? this.$initialCell ?? undefined), + }; + + this.options.getAppointmentDraggingConfig() + .onDragEnd?.(e as unknown as AppointmentDraggingEndEvent); - if (isSameCell || !isSameScheduler) { + if (e.cancel === true) { + this.removeDraggingClasses($(e.itemElement)); + return; + } + + if (!isSameComponent) { + this.options.getAppointmentDraggingConfig() + .onRemove?.(e as unknown as AppointmentDraggingRemoveEvent); + this.removeDraggingClasses($(e.itemElement)); + return; + } + + const isSameCell = this.$initialCell?.is($dropCell ?? $()) ?? false; + + if (!$dropCell || isSameCell) { this.removeDraggingClasses($(e.itemElement)); return; } this.options.updateAppointmentOnDrop( - e.itemData.appointmentData, - e.itemData.targetedAppointmentData, - this.$highlightedCell, + draggedItemData.appointmentData, + draggedItemData.targetedAppointmentData, + $dropCell, ) .finally(() => { this.removeDraggingClasses($(e.itemElement)); }) .catch((err) => { throw err; }); @@ -200,8 +318,74 @@ export class AppointmentDragController { this.removeCellHighlight(); } + private getSourceDropCell(e: DragEndEvent): dxElementWrapper | null { + const $cell = this.$highlightedCell; + + if (!$cell || $cell.closest(e.fromComponent.$element()).length === 0) { + return null; + } + + return $cell; + } + + private onDrop(e: DragEndEvent): void { + if (e.fromComponent === e.toComponent) { + return; + } + + const $cell = this.getDropCell(e) ?? undefined; + + (e as { itemData?: unknown }).itemData = { + ...e.itemData, + ...this.options.getUpdatedItemData(e.itemData, $cell), + }; + + this.options.getAppointmentDraggingConfig() + .onAdd?.(e as unknown as AppointmentDraggingAddEvent); + + this.removeCellHighlight(); + } + + private getDropCell(e: DragEndEvent): dxElementWrapper | null { + const point = this.getDropPoint(e); + const $cellFromPoint = point + ? this.options.getCellFromPoint(point.x, point.y) + : null; + + if ($cellFromPoint) { + return $cellFromPoint; + } + + const $droppableCell = this.options.getDroppableCell(); + return $droppableCell.length ? $droppableCell : null; + } + + private getDropPoint(e: DragEndEvent): { x: number; y: number } | null { + const { event } = e as { + event?: { clientX?: number; clientY?: number; pageX?: number; pageY?: number }; + }; + + if (!event) { + return null; + } + + const window = getWindow(); + + const toClient = ( + client: number | undefined, + page: number | undefined, + scroll: number, + ): number | undefined => client ?? (page != null ? page - (scroll || 0) : undefined); + + const x = toClient(event.clientX, event.pageX, window.scrollX); + const y = toClient(event.clientY, event.pageY, window.scrollY); + + return x != null && y != null ? { x, y } : null; + } + // Note: onDragCancel is private callback, so there's no type for it private onDragCancel(e: DragEndEvent): void { + this.draggedItemData = null; this.removeDraggingClasses($(e.itemElement)); } @@ -211,13 +395,18 @@ export class AppointmentDragController { } private highlightCell($cell: dxElementWrapper): void { - this.removeCellHighlight(); + this.removeHighlightedCell(); $cell.addClass(HIGHLIGHTED_CELL_CLASS); this.$highlightedCell = $cell; } - private removeCellHighlight(): void { + private removeHighlightedCell(): void { this.$highlightedCell?.removeClass(HIGHLIGHTED_CELL_CLASS); this.$highlightedCell = null; } + + private removeCellHighlight(): void { + this.removeHighlightedCell(); + this.options.getDroppableCell().removeClass(HIGHLIGHTED_CELL_CLASS); + } } diff --git a/packages/devextreme/js/__internal/scheduler/scheduler.ts b/packages/devextreme/js/__internal/scheduler/scheduler.ts index d891c6bdc1f1..45d861cfb1a2 100644 --- a/packages/devextreme/js/__internal/scheduler/scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/scheduler.ts @@ -243,6 +243,8 @@ interface SchedulerWorkSpaceLike { option: (name: string | Record, value?: unknown) => unknown; getDateRange: () => Date[]; getCellFromDragTarget: ($dragTarget: dxElementWrapper) => dxElementWrapper | null; + getCellFromPoint: (x: number, y: number) => dxElementWrapper | null; + getDroppableCell: () => dxElementWrapper; renderAgendaLayout?: (viewModel: AppointmentViewModelPlain[]) => void; calculateEndDate: (startDate: Date) => Date; updateScrollPosition: (date: Date, groupValues: GroupValues, inAllDayRow: boolean) => void; @@ -1069,11 +1071,20 @@ class Scheduler extends SchedulerOptionsBaseWidget { getCellFromDragTarget: ($dragTarget: dxElementWrapper): dxElementWrapper | null => ( this._workSpace.getCellFromDragTarget($dragTarget) ), + getCellFromPoint: (x: number, y: number): dxElementWrapper | null => ( + this._workSpace.getCellFromPoint(x, y) + ), + getDroppableCell: (): dxElementWrapper => this._workSpace.getDroppableCell(), // @ts-expect-error _createComponent is not defined in ts createComponent: this._createComponent.bind(this), hideAppointmentTooltip: this.hideAppointmentTooltip.bind(this), + getAppointmentDraggingConfig: (): NonNullable => ( + this.option('appointmentDragging') ?? {} + ), + getUpdatedItemData: this.getUpdatedData.bind(this), + updateAppointmentOnDrop: this.updateAppointmentOnDrop.bind(this), }); } diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts index 7655a5a89327..9a77e6793832 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts @@ -2511,8 +2511,12 @@ class SchedulerWorkSpace extends Widget { } const point = this.getPointFromDragTarget($dragTarget); + return this.getCellFromPoint(point.x, point.y); + } + + public getCellFromPoint(x: number, y: number): dxElementWrapper | null { // @ts-expect-error - const elements = domAdapter.elementsFromPoint(point.x, point.y) as Element[]; + const elements = domAdapter.elementsFromPoint(x, y) as Element[]; const cell = elements.find((element) => element.classList.contains('dx-scheduler-date-table-cell') || element.classList.contains('dx-scheduler-all-day-table-cell'));