From fe9a52a5eda5e0547ee0d07ceab62e8aca9880cc Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Mon, 25 May 2026 15:49:55 +0800 Subject: [PATCH 1/6] implement --- .../scheduler/appointment_drag_controller.ts | 208 ++++++++++++++++++ .../appointments_new/appointments.test.ts | 57 +++-- .../appointments_new/appointments.ts | 120 ++++++---- .../scheduler/m_appointment_drag_behavior.ts | 1 + .../js/__internal/scheduler/m_scheduler.ts | 107 ++++++++- .../js/__internal/scheduler/m_subscribes.ts | 4 + .../desktop_tooltip_strategy.ts | 13 +- .../tooltip_strategy_base.ts | 8 + .../js/__internal/scheduler/types.ts | 7 +- .../scheduler/workspaces/m_work_space.ts | 51 +++++ 10 files changed, 488 insertions(+), 88 deletions(-) create mode 100644 packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts diff --git a/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts b/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts new file mode 100644 index 000000000000..31f6552ea335 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts @@ -0,0 +1,208 @@ +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import type { + DragEndEvent, DragMoveEvent, DragStartEvent, DragTemplateData, Properties as DraggableProperties, +} from '@js/ui/draggable'; +import type { Appointment } from '@js/ui/scheduler'; +import { getHeight, getWidth } from '@ts/core/utils/m_size'; +import Draggable from '@ts/m_draggable'; + +import { APPOINTMENT_CLASSES } from './appointments_new/const'; +import type Scheduler from './m_scheduler'; +import type { AppointmentTooltipItem } from './types'; +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'; + +export interface AppointmentDragControllerOptions { + component: Scheduler; + $draggableContainer: () => dxElementWrapper; + canDragAppointment: (appointmentData: Appointment) => boolean; + getCellFromDragTarget: ($dragTarget: dxElementWrapper) => dxElementWrapper | null; + + createComponent: ( + $element: dxElementWrapper, + componentType: new () => T, + options: object, + ) => T; + hideAppointmentTooltip: () => void; + + updateAppointmentOnDrop: ( + appointmentData: Appointment, + $cell: dxElementWrapper, + event: DragEndEvent, + ) => void; +} + +export interface WorkSpaceDraggableOptions { + getAppointmentData: ($element: dxElementWrapper) => Appointment; +} + +export interface TooltipDraggableOptions { + dragTemplate: (appointmentViewModel: AppointmentItemViewModel) => dxElementWrapper; +} + +export class AppointmentDragController { + private workSpaceDraggable: Draggable | null = null; + + private tooltipDraggable: Draggable | null = null; + + private $initialCell: dxElementWrapper | null = null; + + private $highlightedCell: dxElementWrapper | null = null; + + private $dragClone: dxElementWrapper | null = null; + + constructor( + private readonly options: AppointmentDragControllerOptions, + ) { } + + public createWorkSpaceDraggable( + $workSpace: dxElementWrapper, + draggableOptions: WorkSpaceDraggableOptions, + ): void { + const config: DraggableProperties = { + ...this.getCommonDraggableConfig(), + // @ts-expect-error private option + filter: `.${APPOINTMENT_CLASSES.CONTAINER}`, + dragTemplate: (dragInfo: DragTemplateData) => { + this.$dragClone = $(dragInfo.itemElement).clone(); + this.$dragClone.css({ top: '', left: '' }); + this.$dragClone.removeClass(APPOINTMENT_DRAG_SOURCE_CLASS); + + return this.$dragClone; + }, + onDragStart: (e: DragStartEvent) => { + e.itemData = draggableOptions.getAppointmentData($(e.itemElement)); + + this.onDragStart(e); + }, + }; + + this.workSpaceDraggable = this.options.createComponent($workSpace, Draggable, config); + } + + public disposeWorkSpaceDraggable(): void { + this.workSpaceDraggable?.dispose(); + this.workSpaceDraggable = null; + } + + public createTooltipDraggable( + $tooltipList: dxElementWrapper, + draggableOptions: TooltipDraggableOptions, + ): void { + let draggingTooltipItem: AppointmentTooltipItem | null = null; + + const config: DraggableProperties = { + ...this.getCommonDraggableConfig(), + filter: `.${TOOLTIP_LIST_ITEM_CLASS}`, + dragTemplate: () => { + if (!draggingTooltipItem) { + return $(); + } + + const appointmentViewModel = draggingTooltipItem.settings; + + this.$dragClone = draggableOptions.dragTemplate(appointmentViewModel); + this.$dragClone.css({ top: '', left: '' }); + this.$dragClone.removeClass(APPOINTMENT_DRAG_SOURCE_CLASS); + + return this.$dragClone; + }, + onDragStart: (e: DragStartEvent) => { + draggingTooltipItem = $(e.itemElement).data('dxListItemData') as unknown as AppointmentTooltipItem; + e.itemData = draggingTooltipItem.appointment; + + this.onDragStart(e); + }, + // @ts-expect-error private option + cursorOffset: () => ({ + x: getWidth(this.$dragClone) / 2, + y: getHeight(this.$dragClone) / 2, + }), + }; + + this.tooltipDraggable = this.options.createComponent($tooltipList, Draggable, config); + } + + public disposeTooltipDraggable(): void { + this.tooltipDraggable?.dispose(); + this.tooltipDraggable = null; + } + + private getCommonDraggableConfig(): DraggableProperties { + return { + // @ts-expect-error private option + component: this.options.component, + container: this.options.$draggableContainer().get(0), + onCancelByEsc: true, + onDragMove: this.onDragMove.bind(this), + onDragEnd: this.onDragEnd.bind(this), + onDragCancel: this.onDragCancel.bind(this), + }; + } + + private onDragStart(e: DragStartEvent): void { + if (!this.options.canDragAppointment(e.itemData)) { + e.cancel = true; + return; + } + + this.$initialCell = this.options.getCellFromDragTarget($(e.itemElement)); + + this.options.hideAppointmentTooltip(); + + $(e.itemElement).addClass(APPOINTMENT_DRAG_SOURCE_CLASS); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private onDragMove(e: DragMoveEvent): void { + const $cell = this.options.getCellFromDragTarget($(this.$dragClone)); + + if (!$cell) { + this.removeHighlight(); + return; + } + + this.highlightCell($cell); + } + + private onDragEnd(e: DragEndEvent): void { + if (!this.$highlightedCell) { + this.removeDraggingClasses($(e.itemElement)); + return; + } + + const isSameCell = this.$initialCell?.is(this.$highlightedCell) ?? false; + const isSameScheduler = this.$highlightedCell.closest(e.fromComponent.$element()).length > 0; + + if (!isSameCell && isSameScheduler) { + this.options.updateAppointmentOnDrop(e.itemData, this.$highlightedCell, e); + } + + this.removeDraggingClasses($(e.itemElement)); + } + + private onDragCancel(e): void { + this.removeDraggingClasses($(e.itemElement)); + } + + public removeDraggingClasses($dragSource: dxElementWrapper): void { + $dragSource.removeClass(APPOINTMENT_DRAG_SOURCE_CLASS); + this.$dragClone = null; + this.removeHighlight(); + } + + private highlightCell($cell: dxElementWrapper): void { + this.removeHighlight(); + $cell.addClass(HIGHLIGHTED_CELL_CLASS); + this.$highlightedCell = $cell; + } + + private removeHighlight(): void { + this.$highlightedCell?.removeClass(HIGHLIGHTED_CELL_CLASS); + this.$highlightedCell = null; + } +} diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts index 4cab9eee32f2..feb867d56ebe 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -51,8 +51,7 @@ const getProperties = (options: { getResourceManager: () => getResourceManagerMock(options.resources ?? []), getDataAccessor: () => mockAppointmentDataAccessor, - showTooltipForAppointment: (): void => {}, - showTooltipForCollector: (): void => {}, + showAppointmentTooltip: (): void => {}, showEditAppointmentPopup: (): void => {}, allowDelete: false, onDeleteKeyPress: (): void => {}, @@ -1174,7 +1173,7 @@ describe('Appointments', () => { }); it('should show tooltip when Enter is pressed on appointment collector', () => { - const showTooltipForCollector = jest.fn(); + const showAppointmentTooltip = jest.fn(); const showEditAppointmentPopup = jest.fn(); const viewModel = [ mockAppointmentCollectorViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), @@ -1182,7 +1181,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), - showTooltipForCollector, + showAppointmentTooltip, showEditAppointmentPopup, }); instance.option('viewModel', viewModel); @@ -1190,12 +1189,12 @@ describe('Appointments', () => { const viewItem = instance.getViewItemBySortedIndex(0); fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: 'Enter' }); - expect(showTooltipForCollector).toHaveBeenCalledTimes(1); + expect(showAppointmentTooltip).toHaveBeenCalledTimes(1); expect(showEditAppointmentPopup).not.toHaveBeenCalled(); }); it('should show tooltip when Space is pressed on appointment collector', () => { - const showTooltipForCollector = jest.fn(); + const showAppointmentTooltip = jest.fn(); const showEditAppointmentPopup = jest.fn(); const viewModel = [ mockAppointmentCollectorViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), @@ -1203,7 +1202,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), - showTooltipForCollector, + showAppointmentTooltip, showEditAppointmentPopup, }); instance.option('viewModel', viewModel); @@ -1211,7 +1210,7 @@ describe('Appointments', () => { const viewItem = instance.getViewItemBySortedIndex(0); fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: ' ' }); - expect(showTooltipForCollector).toHaveBeenCalledTimes(1); + expect(showAppointmentTooltip).toHaveBeenCalledTimes(1); expect(showEditAppointmentPopup).not.toHaveBeenCalled(); }); }); @@ -1331,14 +1330,12 @@ describe('Appointments', () => { it('should prevent tooltip showing when onAppointmentClick callback sets e.cancel = true', () => { const onAppointmentClick = jest.fn((e) => { (e as any).cancel = true; }); - const showTooltipForAppointment = jest.fn(); - const showTooltipForCollector = jest.fn(); + const showAppointmentTooltip = jest.fn(); const instance = createAppointments({ ...getProperties(), onAppointmentClick, - showTooltipForAppointment, - showTooltipForCollector, + showAppointmentTooltip, }); instance.option('viewModel', [ mockGridViewModel(defaultAppointmentData, { sortedIndex: 0 }), @@ -1352,16 +1349,15 @@ describe('Appointments', () => { jest.runAllTimers(); expect(onAppointmentClick).toHaveBeenCalledTimes(1); - expect(showTooltipForAppointment).not.toHaveBeenCalled(); - expect(showTooltipForCollector).not.toHaveBeenCalled(); + expect(showAppointmentTooltip).not.toHaveBeenCalled(); }); it('should show tooltip correctly when two appointments are clicked one after another quickly', () => { - const showTooltipForAppointment = jest.fn(); + const showAppointmentTooltip = jest.fn(); const instance = createAppointments({ ...getProperties(), - showTooltipForAppointment, + showAppointmentTooltip, }); instance.option('viewModel', [ mockGridViewModel({ ...defaultAppointmentData, text: 'Appointment 1' }, { sortedIndex: 0 }), @@ -1379,11 +1375,14 @@ describe('Appointments', () => { element2.click(); jest.runAllTimers(); - expect(showTooltipForAppointment).toHaveBeenCalledTimes(1); - expect(showTooltipForAppointment).toHaveBeenCalledWith( - expect.objectContaining({ text: 'Appointment 2' }), + expect(showAppointmentTooltip).toHaveBeenCalledTimes(1); + expect(showAppointmentTooltip).toHaveBeenCalledWith( $(element2), - expect.objectContaining({ text: 'Appointment 2' }), + [ + expect.objectContaining({ + appointment: expect.objectContaining({ text: 'Appointment 2' }), + }), + ], ); }); }); @@ -1441,14 +1440,12 @@ describe('Appointments', () => { it('should show appointment popup on appointment double click', () => { const showEditAppointmentPopup = jest.fn(); - const showTooltipForAppointment = jest.fn(); - const showTooltipForCollector = jest.fn(); + const showAppointmentTooltip = jest.fn(); const instance = createAppointments({ ...getProperties(), showEditAppointmentPopup, - showTooltipForAppointment, - showTooltipForCollector, + showAppointmentTooltip, }); instance.option('viewModel', [ mockGridViewModel(defaultAppointmentData, { sortedIndex: 0 }), @@ -1468,8 +1465,7 @@ describe('Appointments', () => { ...defaultAppointmentData, }), ); - expect(showTooltipForAppointment).not.toHaveBeenCalled(); - expect(showTooltipForCollector).not.toHaveBeenCalled(); + expect(showAppointmentTooltip).not.toHaveBeenCalled(); }); it('should not show appointment popup on collector double click', () => { @@ -1495,15 +1491,13 @@ describe('Appointments', () => { it('should not show tooltip or appointment popup if onAppointmentDblClick sets e.cancel', () => { const onAppointmentDblClick = jest.fn((e) => { (e as any).cancel = true; }); const showEditAppointmentPopup = jest.fn(); - const showTooltipForAppointment = jest.fn(); - const showTooltipForCollector = jest.fn(); + const showAppointmentTooltip = jest.fn(); const instance = createAppointments({ ...getProperties(), onAppointmentDblClick, showEditAppointmentPopup, - showTooltipForAppointment, - showTooltipForCollector, + showAppointmentTooltip, }); instance.option('viewModel', [ mockGridViewModel(defaultAppointmentData, { sortedIndex: 0 }), @@ -1518,8 +1512,7 @@ describe('Appointments', () => { expect(onAppointmentDblClick).toHaveBeenCalledTimes(1); expect(showEditAppointmentPopup).not.toHaveBeenCalled(); - expect(showTooltipForAppointment).not.toHaveBeenCalled(); - expect(showTooltipForCollector).not.toHaveBeenCalled(); + expect(showAppointmentTooltip).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index 5b726c43118a..50a5c8a53e20 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -33,7 +33,7 @@ import type { SortedEntity, } from '../view_model/types'; import { AgendaAppointmentView } from './appointment/agenda_appointment'; -import type { BaseAppointmentView, BaseAppointmentViewProperties } from './appointment/base_appointment'; +import { BaseAppointmentView, type BaseAppointmentViewProperties } from './appointment/base_appointment'; import { GridAppointmentView } from './appointment/grid_appointment'; import { AppointmentCollector } from './appointment_collector'; import { AppointmentsFocusController } from './appointments.focus_controller'; @@ -68,12 +68,7 @@ export interface AppointmentsProperties extends DOMComponentProperties boolean; scrollTo: (date: Date, options?: ScrollToOptions) => void; - showTooltipForAppointment: ( - appointment: SafeAppointment, - $element: dxElementWrapper, - targetedAppointment?: SafeAppointment, - ) => void; - showTooltipForCollector: ( + showAppointmentTooltip: ( target: dxElementWrapper, data: AppointmentTooltipItem[], options?: AppointmentTooltipExtraOptions, @@ -107,6 +102,22 @@ export class Appointments extends DOMComponent viewModel.sortedIndex === sortedIndex, + )!; + return result; + } + + public getAppointmentData($element: dxElementWrapper): SafeAppointment { + const viewItem = this.viewItems.find( + (item: ViewItem) => item.$element().is($element), + ); + + return (viewItem as BaseAppointmentView).appointmentData; + } + public get $allDayContainer(): dxElementWrapper | null { return this.option().$allDayContainer; } @@ -181,6 +192,16 @@ export class Appointments extends DOMComponent Boolean(item.needToAdd ?? item.needToRemove), + ); + + if (isRepaintAll) { + this.renderViewModel(args.value); + break; + } + this.renderViewModelDiff(diff); break; } @@ -238,12 +259,13 @@ export class Appointments extends DOMComponent { + const viewItem = this.renderViewItem(viewModelItem, index); + this.viewItemBySortedIndex[viewModelItem.sortedIndex] = viewItem; + const container = this.option().currentView === 'agenda' || !viewModelItem.allDay ? commonFragment : allDayFragment; - - const viewItem = this.renderViewItem(container, viewModelItem, index); - this.viewItemBySortedIndex[viewModelItem.sortedIndex] = viewItem; + container.appendChild(viewItem.$element().get(0)); }); this.viewItems = Object.values(this.viewItemBySortedIndex); @@ -260,16 +282,6 @@ export class Appointments extends DOMComponent = {}; - const isRepaintAll = viewModelDiff.every( - (item) => Boolean(item.needToAdd ?? item.needToRemove), - ); - - if (isRepaintAll) { - this.$allDayContainer?.empty(); - this.$commonContainer.empty(); - } - - // TODO: remove passing index to appointmentTemplate, need only to avoid BC viewModelDiff.forEach((diffItem, index) => { const { allDay, sortedIndex } = diffItem.item; const lookupIndex = diffItem.oldSortedIndex ?? sortedIndex; @@ -277,18 +289,15 @@ export class Appointments extends DOMComponent'); - - fragment.appendChild($element.get(0)); - const targetedAppointmentData = this.getTargetedAppointmentData(appointmentViewModel); - const baseViewItemConfig = { tabIndex: -1, sortedIndex: appointmentViewModel.sortedIndex, @@ -401,6 +406,10 @@ export class Appointments extends DOMComponent ({ - appointment: appointmentViewModel.itemData, - targetedAppointment: this.getTargetedAppointmentData(appointmentViewModel), - color: this.getResourceColor(appointmentViewModel), - settings: appointmentViewModel, - })); - - this.option().showTooltipForCollector( + this.option().showAppointmentTooltip( collector.$element(), - collectorTooltipItems, + this.getTooltipItems(collector), { - dragBehavior: undefined, // TODO isButtonClick: true, tabFocusLoopEnabled: true, }, ); } + + private getTooltipItems(viewItem: ViewItem): AppointmentTooltipItem[] { + if (viewItem instanceof AppointmentCollector) { + const tooltipItems: AppointmentTooltipItem[] = viewItem.option().items.map( + (appointmentViewModel) => ({ + appointment: appointmentViewModel.itemData, + targetedAppointment: this.getTargetedAppointmentData(appointmentViewModel), + color: this.getResourceColor(appointmentViewModel), + settings: appointmentViewModel, + }), + ); + + return tooltipItems; + } + + if (viewItem instanceof BaseAppointmentView) { + const viewModel = this.getViewModelBySortedIndex( + viewItem.option().sortedIndex, + ) as AppointmentItemViewModel; + + return [{ + appointment: viewItem.appointmentData, + targetedAppointment: viewItem.targetedAppointmentData, + color: this.getResourceColor(viewItem.option()), + settings: viewModel, + }]; + } + + return []; + } } // TODO: rename to dxSchedulerAppointments when old impl is removed diff --git a/packages/devextreme/js/__internal/scheduler/m_appointment_drag_behavior.ts b/packages/devextreme/js/__internal/scheduler/m_appointment_drag_behavior.ts index 7d7b4a787f75..0eb2571b8bec 100644 --- a/packages/devextreme/js/__internal/scheduler/m_appointment_drag_behavior.ts +++ b/packages/devextreme/js/__internal/scheduler/m_appointment_drag_behavior.ts @@ -9,6 +9,7 @@ import type { AppointmentViewModelPlain } from './view_model/types'; const APPOINTMENT_ITEM_CLASS = 'dx-scheduler-appointment'; +// TODO: delete this file when old impl is removed export default class AppointmentDragBehavior { workspace = this.scheduler._workSpace; diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index 3db787a341c0..b91c1268850e 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -28,6 +28,7 @@ import { import { hasWindow } from '@js/core/utils/window'; import DataHelperMixin from '@js/data_helper'; import { custom as customDialog } from '@js/ui/dialog'; +import type { DragEndEvent } from '@js/ui/draggable'; import type { Appointment, AppointmentTooltipShowingEvent, DayOfWeek, Occurrence, } from '@js/ui/scheduler'; @@ -36,6 +37,7 @@ import { dateUtilsTs } from '@ts/core/utils/date'; import { createA11yStatusContainer } from './a11y_status/a11y_status_render'; import { getA11yStatusText } from './a11y_status/a11y_status_text'; +import { AppointmentDragController } from './appointment_drag_controller'; import type { AppointmentFormConfig } from './appointment_popup/form'; import { AppointmentForm } from './appointment_popup/form'; import { AppointmentPopup } from './appointment_popup/popup'; @@ -177,6 +179,8 @@ class Scheduler extends SchedulerOptionsBaseWidget { // TODO: used externally in m_appointment_drag_behavior.ts, m_subscribes.ts, workspaces/m_work_space.ts _appointments: any; + private appointmentDragController!: AppointmentDragController; + appointmentDataSource!: AppointmentDataSource; _dataSource: any; @@ -222,6 +226,8 @@ class Scheduler extends SchedulerOptionsBaseWidget { private mainContainer: any; + private $draggableContainer?: dxElementWrapper; + private readonly all: any; _options: any; @@ -625,10 +631,14 @@ class Scheduler extends SchedulerOptionsBaseWidget { return this.currentView.type === 'agenda'; } - private allowDragging() { + private allowDragging(): boolean { return this.editing.allowDragging && !this.isAgenda(); } + private canDragAppointment(appointmentData: Appointment): boolean { + return this.allowDragging() && !this._isAppointmentBeingUpdated(appointmentData); + } + private allowResizing() { return this.editing.allowResizing && !this.isAgenda(); } @@ -824,6 +834,19 @@ class Scheduler extends SchedulerOptionsBaseWidget { this.resourceManager = new ResourceManager(this.option('resources')); this.notifyScheduler = new NotifyScheduler({ scheduler: this }); + + this.appointmentDragController = new AppointmentDragController({ + component: this, + $draggableContainer: () => this.$draggableContainer!, + canDragAppointment: this.canDragAppointment.bind(this), + getCellFromDragTarget: ($dragTarget) => this._workSpace.getCellFromDragTarget($dragTarget), + + // @ts-expect-error _createComponent is not defined in ts + createComponent: this._createComponent.bind(this), + hideAppointmentTooltip: this.hideAppointmentTooltip.bind(this), + + updateAppointmentOnDrop: this.updateAppointmentOnDrop.bind(this), + }); } createAppointmentDataSource() { @@ -1098,8 +1121,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { isVirtualScrolling: () => this.isVirtualScrolling(), scrollTo: this.scrollTo.bind(this), - showTooltipForAppointment: this.showAppointmentTooltip.bind(this), - showTooltipForCollector: this.showAppointmentTooltipCore.bind(this), + showAppointmentTooltip: this.showAppointmentTooltipCore.bind(this), showEditAppointmentPopup: ( appointmentData: SafeAppointment, targetedAppointmentData: TargetedAppointment, @@ -1146,6 +1168,9 @@ class Scheduler extends SchedulerOptionsBaseWidget { private renderMainContainer() { this.mainContainer = $('
').addClass('dx-scheduler-container'); + this.$draggableContainer = $('
') + .addClass('dx-scheduler-draggable-container') + .appendTo(this.mainContainer); this.$element().append(this.mainContainer); } @@ -1226,8 +1251,22 @@ class Scheduler extends SchedulerOptionsBaseWidget { getAppointmentDisabled: (appointment) => this._dataAccessors.get('disabled', appointment), onItemContextMenu: that._createActionByOption('onAppointmentContextMenu'), createEventArgs: that._createEventArgs.bind(that), + newAppointments: Boolean(this.option('_newAppointments')), onAppointmentClick: (...args) => this.actions.onAppointmentClick(...args), + onListInitialized: (e) => { + if (this.option('_newAppointments')) { + this.appointmentDragController.createTooltipDraggable( + e.element, + { + dragTemplate: this._appointments.renderDragClone.bind(this._appointments), + }, + ); + } + }, + onListDisposing: () => { + this.appointmentDragController.disposeTooltipDraggable(); + }, }; } @@ -1422,7 +1461,9 @@ class Scheduler extends SchedulerOptionsBaseWidget { // @ts-expect-error this._workSpace = this._createComponent($workSpace, workSpaceComponent, workSpaceConfig); - this.allowDragging() && this._workSpace.initDragBehavior(this, this.all); + if (!this.option('_newAppointments')) { + this.allowDragging() && this._workSpace.initDragBehavior(this, this.all); + } this._workSpace.attachTablesEvents(); this._workSpace.getWorkArea().append(this._appointments.$element()); } @@ -1458,6 +1499,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { || isTimelineView(currentViewOptions.type); const result = extend({ + newAppointments: Boolean(this.option('_newAppointments')), resources: this.option('resources'), getResourceManager: () => this.resourceManager, getFilteredItems: () => this._layoutManager.filteredItems, // NOTE: used only in agenda @@ -1506,6 +1548,21 @@ class Scheduler extends SchedulerOptionsBaseWidget { onShowAllDayPanel: (value) => this.option('showAllDayPanel', value), getHeaderHeight: () => utils.DOM.getHeaderHeight(this.header), onScrollEnd: () => this._appointments.updateResizableArea(), + onInitialized: (e) => { + if (this.option('_newAppointments')) { + this.appointmentDragController.createWorkSpaceDraggable( + e.element, + { + getAppointmentData: this._appointments.getAppointmentData.bind(this._appointments), + }, + ); + } + }, + onDisposing: () => { + if (this.option('_newAppointments')) { + this.appointmentDragController.disposeWorkSpaceDraggable(); + } + }, // TODO: SSR does not work correctly with renovated render renovateRender: this.isRenovatedRender(isVirtualScrolling), @@ -1609,6 +1666,41 @@ class Scheduler extends SchedulerOptionsBaseWidget { this.appointmentPopup?.dispose(); } + private updateAppointmentOnDrop( + appointmentData: Appointment, + $cell: dxElementWrapper, + dragEvent: DragEndEvent, + ): void { + const updatedData = this.getUpdatedData(appointmentData, $cell); + const newAppointmentData = extend({}, appointmentData, updatedData); + const startDate = this._dataAccessors.get('startDate', newAppointmentData); + + this.checkRecurringAppointment( + appointmentData, + newAppointmentData, + startDate, + () => { + this.updateAppointmentCore( + appointmentData, + newAppointmentData, + (): void => { + if (isDeferred(dragEvent.cancel)) { + (dragEvent.cancel as any).resolve?.(); + } else { + dragEvent.cancel = true; + } + + this.appointmentDragController.removeDraggingClasses($(dragEvent.itemElement)); + }, + dragEvent, + ); + }, + undefined, + undefined, + dragEvent, + ); + } + checkRecurringAppointment( rawAppointment, singleAppointment, @@ -1743,7 +1835,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { return this.recurrenceDialog.show(); } - getUpdatedData(rawAppointment) { + getUpdatedData(rawAppointment, $cell?) { const viewOffset = this.getViewOffsetMs(); const getConvertedFromGrid = (date: any): Date | undefined => { @@ -1755,7 +1847,9 @@ class Scheduler extends SchedulerOptionsBaseWidget { return dateUtilsTs.addOffsets(result, -viewOffset); }; - const targetCell = this.getTargetCellData(); + const targetCell = $cell + ? this._workSpace.getCellData($cell) + : this.getTargetCellData(); const appointment = new AppointmentAdapter( rawAppointment, this._dataAccessors, @@ -2119,6 +2213,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { appointment, targetedAppointment, color: this.resourceManager.getAppointmentColor(appointmentConfig), + settings, }; this.showAppointmentTooltipCore(element, [info]); diff --git a/packages/devextreme/js/__internal/scheduler/m_subscribes.ts b/packages/devextreme/js/__internal/scheduler/m_subscribes.ts index d3e320eb3a5e..d530559a8b82 100644 --- a/packages/devextreme/js/__internal/scheduler/m_subscribes.ts +++ b/packages/devextreme/js/__internal/scheduler/m_subscribes.ts @@ -75,10 +75,12 @@ const subscribes = { }); }, + // TODO: delete this method when old impl is removed getUpdatedData(rawAppointment) { return this.getUpdatedData(rawAppointment); }, + // TODO: delete this method when old impl is removed updateAppointmentAfterDrag({ event, element, rawAppointment, isDropToTheSameCell, isDropToSelfScheduler, }) { @@ -110,6 +112,7 @@ const subscribes = { } }, + // TODO: delete this method when old impl is removed onDeleteButtonPress(options) { const targetedData = this.getTargetedAppointment(options.data, $(options.target)); this.checkAndDeleteAppointment(options.data, targetedData); @@ -297,6 +300,7 @@ const subscribes = { return this.option('adaptivityEnabled'); }, + // TODO: delete this method when old impl is removed removeDroppableCellClass() { this._workSpace.removeDroppableCellClass(); }, diff --git a/packages/devextreme/js/__internal/scheduler/tooltip_strategies/desktop_tooltip_strategy.ts b/packages/devextreme/js/__internal/scheduler/tooltip_strategies/desktop_tooltip_strategy.ts index f0c87f33febf..88d2ac84755c 100644 --- a/packages/devextreme/js/__internal/scheduler/tooltip_strategies/desktop_tooltip_strategy.ts +++ b/packages/devextreme/js/__internal/scheduler/tooltip_strategies/desktop_tooltip_strategy.ts @@ -1,6 +1,8 @@ import messageLocalization from '@js/common/core/localization/message'; import type { dxElementWrapper } from '@js/core/renderer'; -import type { ContentReadyEvent, ItemContextMenuEvent, Properties as ListProperties } from '@js/ui/list'; +import type { + ContentReadyEvent, InitializedEvent, ItemContextMenuEvent, Properties as ListProperties, +} from '@js/ui/list'; import supportUtils from '@ts/core/utils/m_support'; import Tooltip from '@ts/ui/m_tooltip'; @@ -68,9 +70,18 @@ export class DesktopTooltipStrategy extends TooltipStrategyBase { return tooltip; } + protected override onListInitialized(e: InitializedEvent): void { + this._options.onListInitialized(e); + } + + // TODO: remove when old impl is removed protected override onListRender( e: ContentReadyEvent, ): void { + if (this._options.newAppointments) { + return; + } + if (this.extraOptions?.dragBehavior) { this.extraOptions.dragBehavior(e); } diff --git a/packages/devextreme/js/__internal/scheduler/tooltip_strategies/tooltip_strategy_base.ts b/packages/devextreme/js/__internal/scheduler/tooltip_strategies/tooltip_strategy_base.ts index 0552dc7cd944..280f5f728c1e 100644 --- a/packages/devextreme/js/__internal/scheduler/tooltip_strategies/tooltip_strategy_base.ts +++ b/packages/devextreme/js/__internal/scheduler/tooltip_strategies/tooltip_strategy_base.ts @@ -7,6 +7,7 @@ import type { ClickEvent as ButtonClickEvent } from '@js/ui/button'; import Button from '@js/ui/button'; import type { ContentReadyEvent, + InitializedEvent, ItemClickEvent, ItemContextMenuEvent, Properties as ListProperties, @@ -65,6 +66,8 @@ interface AppointmentTooltipOptions { createEventArgs: (e: ItemContextMenuEvent) => unknown; newAppointments?: boolean; // TODO onAppointmentClick: (e: AppointmentClickEvent) => void; + onListInitialized: (e: InitializedEvent) => void; + onListDisposing: () => void; } export interface AppointmentTooltipExtraOptions { @@ -233,6 +236,8 @@ export abstract class TooltipStrategyBase { return { dataSource: dataList, onContentReady: this.onListRender.bind(this), + onInitialized: this.onListInitialized.bind(this), + onDisposing: this._options.onListDisposing, onItemClick: ( e: ItemClickEvent, ): void => this.onListItemClick(e), @@ -250,6 +255,9 @@ export abstract class TooltipStrategyBase { }; } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected onListInitialized(e: InitializedEvent): void { } + // eslint-disable-next-line @typescript-eslint/no-unused-vars protected onListRender(e: ContentReadyEvent): void { } diff --git a/packages/devextreme/js/__internal/scheduler/types.ts b/packages/devextreme/js/__internal/scheduler/types.ts index dcb95f91ca21..80f3f8780aa7 100644 --- a/packages/devextreme/js/__internal/scheduler/types.ts +++ b/packages/devextreme/js/__internal/scheduler/types.ts @@ -4,7 +4,7 @@ import type { Component } from '@ts/core/widget/component'; import type { ResourceLoader } from './utils/loader/resource_loader'; import type { GroupValues, RawGroupValues } from './utils/resource_manager/types'; -import type { AppointmentViewModelPlain } from './view_model/types'; +import type { AppointmentItemViewModel } from './view_model/types'; export type Direction = 'vertical' | 'horizontal'; export type GroupOrientation = 'vertical' | 'horizontal'; @@ -263,14 +263,13 @@ export interface AppointmentTooltipItem { appointment: Appointment; targetedAppointment?: Appointment | TargetedAppointment; color: Promise; + settings: AppointmentItemViewModel; } export interface CompactAppointmentOptions { $container: dxElementWrapper; coordinates: { top: number; left: number }; - items: (AppointmentTooltipItem & { - settings: AppointmentViewModelPlain; - })[]; + items: AppointmentTooltipItem[]; buttonColor: Promise; sortedIndex: number; width: number; diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index 8ccc6d667b8f..17ef16a77cdd 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -12,6 +12,7 @@ import { addNamespace, isMouseEvent } from '@js/common/core/events/utils/index'; import messageLocalization from '@js/common/core/localization/message'; import domAdapter from '@js/core/dom_adapter'; import { getPublicElement } from '@js/core/element'; +import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { noop } from '@js/core/utils/common'; import { compileGetter } from '@js/core/utils/data'; @@ -1188,6 +1189,10 @@ class SchedulerWorkSpace extends Widget { } private attachDragEvents(element) { + if (this.option('newAppointments')) { + return; + } + this.detachDragEvents(element); const onDragEnter = (e) => { @@ -2219,10 +2224,55 @@ class SchedulerWorkSpace extends Widget { ); } + public getCellFromDragTarget($dragTarget: dxElementWrapper): dxElementWrapper | null { + if ($dragTarget.length === 0) { + return null; + } + + const point = this.getPointFromDragTarget($dragTarget); + const elements = document.elementsFromPoint(point.x, point.y); + + const cell = elements.find((element) => element.classList.contains('dx-scheduler-date-table-cell') + || element.classList.contains('dx-scheduler-all-day-table-cell')); + + return cell ? $(cell) : null; + } + + private getPointFromDragTarget($dragTarget: dxElementWrapper): { x: number; y: number } { + const THRESHOLD = 10; + + const dragElementContainer = $dragTarget.get(0); + const rect = dragElementContainer.getBoundingClientRect(); + + const cellWidth = this.getCellWidth(); + const isWideAppointment = rect.width > cellWidth; + const isNarrowAppointment = rect.width <= THRESHOLD; + + const x = rect.left; + const y = rect.top; + + if (isWideAppointment) { + return { + x: x + THRESHOLD, + y: y + THRESHOLD, + }; + } + + if (isNarrowAppointment) { + return { x, y }; + } + + return { + x: x + rect.width / 2, + y: y + THRESHOLD, + }; + } + // ------------ // DnD should be removed from work-space // ------------ + // TODO: dragBehavior when old impl is removed initDragBehavior(scheduler) { if (!this.dragBehavior && scheduler) { this.dragBehavior = new AppointmentDragBehavior(scheduler); @@ -3276,6 +3326,7 @@ class SchedulerWorkSpace extends Widget { } } +// TODO: remove dragBehavior when old impl is removed const createDragBehaviorConfig = ( container, rootElement, From f345d0f08ddd634df340a1ff6cc8fa4416ada8c5 Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Tue, 26 May 2026 18:05:04 +0800 Subject: [PATCH 2/6] implement tests --- .../__tests__/__mock__/model/appointment.ts | 2 + .../__tests__/__mock__/model/scheduler.ts | 7 + .../__tests__/__mock__/model/tooltip.ts | 20 +- .../__tests__/appointments_dragging.test.ts | 817 ++++++++++++++++++ .../scheduler/appointment_drag_controller.ts | 23 +- .../js/__internal/scheduler/m_scheduler.ts | 44 +- .../desktopTooltip.tests.js | 1 - 7 files changed, 872 insertions(+), 42 deletions(-) create mode 100644 packages/devextreme/js/__internal/scheduler/__tests__/appointments_dragging.test.ts diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/appointment.ts b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/appointment.ts index 4a5cf02fd5cd..251562694113 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/appointment.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/appointment.ts @@ -13,6 +13,7 @@ export interface AppointmentModel { getColor: (view: string) => string | undefined; getSnapshot: () => object; isFocused: () => boolean; + isDragSource: () => boolean; } const getColor = (appointment: HTMLDivElement): string => appointment.style.backgroundColor; @@ -68,4 +69,5 @@ export const createAppointmentModel = ( ...getGeometry(element), }), isFocused: () => element?.classList.contains('dx-state-focused') ?? false, + isDragSource: () => element?.classList.contains('dx-scheduler-appointment-drag-source') ?? false, }); diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/scheduler.ts b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/scheduler.ts index 5f74e281cd85..be510189b8ef 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/scheduler.ts @@ -54,6 +54,13 @@ export class SchedulerModel { return appointments.map((element) => createAppointmentModel(element as HTMLDivElement)); } + getDraggedAppointment(): AppointmentModel | null { + const draggableContainer = this.container.querySelector('.dx-scheduler-draggable-container'); + const dragClone = draggableContainer?.querySelector('.dx-scheduler-appointment'); + + return dragClone ? createAppointmentModel(dragClone as HTMLDivElement) : null; + } + getTooltipAppointment(index = 0): HTMLElement | null { return this.tooltip.getAppointmentItem(index); } diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/tooltip.ts b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/tooltip.ts index 3afc2f362735..9240e55274b1 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/tooltip.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/tooltip.ts @@ -3,9 +3,11 @@ import $ from '@js/core/renderer'; import type dxTooltip from '@js/ui/tooltip'; import { within } from '@testing-library/dom'; +const TOOLTIP_WIDGET_SELECTOR = ` + .dx-scheduler-appointment-tooltip-wrapper.dx-tooltip.dx-widget +`; const TOOLTIP_WRAPPER_SELECTOR = ` - .dx-overlay-wrapper.dx-scheduler-overlay-panel, - .dx-overlay-wrapper.dx-scheduler-appointment-tooltip-wrapper + .dx-scheduler-appointment-tooltip-wrapper.dx-tooltip-wrapper `; export class TooltipModel { @@ -15,7 +17,7 @@ export class TooltipModel { get dxTooltip(): dxTooltip { // @ts-expect-error - return $('.dx-tooltip.dx-widget').dxTooltip('instance') as dxTooltip; + return $(TOOLTIP_WIDGET_SELECTOR).dxTooltip('instance') as dxTooltip; } get target(): Element | null { @@ -31,6 +33,18 @@ export class TooltipModel { return this.element?.querySelector('.dx-scrollable .dx-scrollview-content') ?? null; } + getList(): Element { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tooltipContent = (this.dxTooltip as any)?.$content().get(0); + const list = tooltipContent?.querySelector('.dx-list.dx-widget'); + + if (!list) { + throw new Error('Tooltip list not found'); + } + + return list as Element; + } + getDeleteButtons(): HTMLElement[] { return this.element ? within(this.element).queryAllByRole('button').filter( diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_dragging.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_dragging.test.ts new file mode 100644 index 000000000000..193f6dd30f04 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_dragging.test.ts @@ -0,0 +1,817 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +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 { createScheduler as baseCreateScheduler } from './__mock__/create_scheduler'; +import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; +import type { SchedulerModel } from './__mock__/model/scheduler'; + +const createScheduler = (config: Properties) => baseCreateScheduler({ + ...config, + // eslint-disable-next-line @typescript-eslint/naming-convention + _newAppointments: true, +}); + +const getDraggableRoot = (POM: SchedulerModel, target: Element): Element => { + const isInWorkspace = target.classList.contains('dx-scheduler-appointment'); + + return isInWorkspace + ? POM.getWorkspace() + : POM.tooltip.getList(); +}; + +const dragStart = (POM: SchedulerModel, target: Element): void => { + eventsEngine.trigger( + getDraggableRoot(POM, target), + { type: 'dxdragstart', target }, + ); +}; + +const dragMove = (POM: SchedulerModel, target: Element, toElement?: Element): void => { + document.elementsFromPoint = jest.fn((): Element[] => ( + toElement ? [toElement] : [document.body] + )); + eventsEngine.trigger( + getDraggableRoot(POM, target), + { type: 'dxdrag', offset: { x: 0, y: 0 } }, + ); +}; + +const dragEnd = (POM: SchedulerModel, target: Element): void => { + eventsEngine.trigger( + getDraggableRoot(POM, target), + { type: 'dxdragend' }, + ); +}; + +describe('Appointments Dragging', () => { + beforeEach(() => { + setupSchedulerTestEnvironment(); + + // Note: used by dragController + document.elementsFromPoint = jest.fn(() => []); + fx.off = true; + }); + + afterEach(() => { + const $scheduler = $('.dx-scheduler'); + // @ts-expect-error + $scheduler.dxScheduler('dispose'); + document.body.innerHTML = ''; + jest.useRealTimers(); + fx.off = false; + }); + + describe('Drag clone', () => { + it('should clone appointment on drag start', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + }); + + const appointment = POM.getAppointments()[0].element; + dragStart(POM, appointment); + + const dragClone = POM.getDraggedAppointment(); + expect(dragClone).not.toBeNull(); + expect(dragClone?.getText()).toBe('Appointment 1'); + expect(dragClone?.isDragSource()).toBe(false); + }); + + it('should create appointment drag clone on tooltip item drag', async () => { + const { POM } = await createScheduler({ + dataSource: [ + { text: 'Apt1', startDate: new Date(2017, 4, 22, 9, 30), endDate: new Date(2017, 4, 22, 10, 30) }, + { text: 'Apt2', startDate: new Date(2017, 4, 22, 9, 30), endDate: new Date(2017, 4, 22, 10, 30) }, + { text: 'Apt3', startDate: new Date(2017, 4, 22, 9, 30), endDate: new Date(2017, 4, 22, 10, 30) }, + ], + views: [{ type: 'day', maxAppointmentsPerCell: 1 }], + currentView: 'day', + currentDate: new Date(2017, 4, 22), + editing: true, + }); + + POM.getCollectorButton().click(); + + const tooltipItem = POM.tooltip.getAppointmentItem(0); + dragStart(POM, tooltipItem); + + const dragClone = POM.getDraggedAppointment(); + + expect(dragClone).not.toBeNull(); + expect(dragClone?.getText()).toBe('Apt2'); + }); + }); + + describe('Cell highlighting', () => { + it('should highlight cell on drag move', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: 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(true); + }); + + it('should remove highlight on drag move if there is no cell', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: 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(true); + + dragMove(POM, appointment); + expect(targetCell.classList.contains('dx-scheduler-date-table-droppable-cell')).toBe(false); + }); + + it('should remove highlight on drag end', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: 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(true); + + dragEnd(POM, appointment); + expect(targetCell.classList.contains('dx-scheduler-date-table-droppable-cell')).toBe(false); + }); + + it('should highlight only one cell at a time', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + }); + + const appointment = POM.getAppointments()[0].element; + const firstCell = POM.getDateTableCell(4, 0); + const secondCell = POM.getDateTableCell(6, 0); + + dragStart(POM, appointment); + dragMove(POM, appointment, firstCell); + + expect(firstCell.classList.contains('dx-scheduler-date-table-droppable-cell')).toBe(true); + + dragMove(POM, appointment, secondCell); + + expect(firstCell.classList.contains('dx-scheduler-date-table-droppable-cell')).toBe(false); + expect(secondCell.classList.contains('dx-scheduler-date-table-droppable-cell')).toBe(true); + }); + }); + + describe('Cancellation', () => { + it('should cancel dragging on esc key press', async () => { + const { POM, keydown } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: 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(true); + + keydown(POM.getWorkspace(), 'Escape'); + + expect(targetCell.classList.contains('dx-scheduler-date-table-droppable-cell')).toBe(false); + expect(POM.getDraggedAppointment()).toBeNull(); + }); + }); + + describe('Hiding tooltip', () => { + it('should hide tooltip on drag start', async () => { + const { POM } = await createScheduler({ + dataSource: [ + { text: 'Apt1', startDate: new Date(2017, 4, 22, 9, 30), endDate: new Date(2017, 4, 22, 10, 30) }, + { text: 'Apt2', startDate: new Date(2017, 4, 22, 9, 30), endDate: new Date(2017, 4, 22, 10, 30) }, + { text: 'Apt3', startDate: new Date(2017, 4, 22, 9, 30), endDate: new Date(2017, 4, 22, 10, 30) }, + ], + views: [{ type: 'day', maxAppointmentsPerCell: 1 }], + currentView: 'day', + currentDate: new Date(2017, 4, 22), + editing: true, + }); + + POM.getCollectorButton().click(); + expect(POM.tooltip.isVisible()).toBe(true); + + const appointment = POM.getAppointments()[0].element; + dragStart(POM, appointment); + + expect(POM.tooltip.isVisible()).toBe(false); + }); + + it('should hide tooltip on tooltip item drag start', async () => { + const { POM } = await createScheduler({ + dataSource: [ + { text: 'Apt1', startDate: new Date(2017, 4, 22, 9, 30), endDate: new Date(2017, 4, 22, 10, 30) }, + { text: 'Apt2', startDate: new Date(2017, 4, 22, 9, 30), endDate: new Date(2017, 4, 22, 10, 30) }, + { text: 'Apt3', startDate: new Date(2017, 4, 22, 9, 30), endDate: new Date(2017, 4, 22, 10, 30) }, + ], + views: [{ type: 'day', maxAppointmentsPerCell: 1 }], + currentView: 'day', + currentDate: new Date(2017, 4, 22), + editing: true, + }); + + POM.getCollectorButton().click(); + expect(POM.tooltip.isVisible()).toBe(true); + + const tooltipItem = POM.tooltip.getAppointmentItem(0); + dragStart(POM, tooltipItem); + + expect(POM.tooltip.isVisible()).toBe(false); + }); + }); + + describe('Source appointment classes', () => { + it('should add class to source appointment', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + }); + + const appointment = POM.getAppointments()[0]; + dragStart(POM, appointment.element); + + expect(appointment.isDragSource()).toBe(true); + }); + + it('should remove class from source appointment on drag end', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + }); + + const appointment = POM.getAppointments()[0]; + + dragStart(POM, appointment.element); + expect(appointment.isDragSource()).toBe(true); + + dragEnd(POM, appointment.element); + expect(appointment.isDragSource()).toBe(false); + }); + + it('should remove class from source appointment on drag cancel', async () => { + const { POM, keydown } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + }); + + const appointment = POM.getAppointments()[0]; + + dragStart(POM, appointment.element); + expect(appointment.isDragSource()).toBe(true); + + keydown(POM.getWorkspace(), 'Escape'); + expect(appointment.isDragSource()).toBe(false); + }); + + it('should remove drag-source class when async appointment update completed', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + onAppointmentUpdating: (e) => { + e.cancel = new Promise((resolve) => { + setTimeout(() => resolve(false), 3000); + }); + }, + }); + + jest.useFakeTimers(); + + const appointment = POM.getAppointments()[0]; + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, appointment.element); + dragMove(POM, appointment.element, targetCell); + dragEnd(POM, appointment.element); + + expect(appointment.isDragSource()).toBe(true); + + await jest.advanceTimersByTimeAsync(3000); + + expect(appointment.isDragSource()).toBe(false); + }); + }); + + describe('Allow dragging', () => { + it.each([ + true, + { allowUpdating: true, allowDragging: true }, + ])('should drag appointment if editing is %j', async (editing) => { + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing, + }); + + const appointment = POM.getAppointments()[0].element; + dragStart(POM, appointment); + + expect(POM.getDraggedAppointment()).not.toBeNull(); + }); + + it.each([ + false, + { allowUpdating: false, allowDragging: true }, + { allowUpdating: true, allowDragging: false }, + ])('should not drag appointment if editing is %j', async (editing) => { + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing, + }); + + const appointment = POM.getAppointments()[0].element; + dragStart(POM, appointment); + + expect(POM.getDraggedAppointment()).toBeNull(); + }); + + it('should not drag appointment if it is still updating', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + onAppointmentUpdating: (e) => { + e.cancel = new Promise(() => {}); + }, + }); + + const appointment = POM.getAppointments()[0]; + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, appointment.element); + dragMove(POM, appointment.element, targetCell); + dragEnd(POM, appointment.element); + + dragStart(POM, appointment.element); + + expect(POM.getDraggedAppointment()).toBeNull(); + expect(appointment.isDragSource()).toBe(true); + expect(targetCell.classList.contains('dx-scheduler-date-table-droppable-cell')).toBe(false); + }); + + it('should allow dragging if another appointment is still updating', async () => { + const { POM } = await createScheduler({ + dataSource: [ + { + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }, + { + text: 'Appointment 2', + startDate: new Date(2015, 1, 9, 10), + endDate: new Date(2015, 1, 9, 11), + }, + ], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + onAppointmentUpdating: (e) => { + e.cancel = new Promise(() => {}); + }, + }); + + const firstAppointment = POM.getAppointments()[0]; + const secondAppointment = POM.getAppointments()[1]; + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, firstAppointment.element); + dragMove(POM, firstAppointment.element, targetCell); + dragEnd(POM, firstAppointment.element); + + dragStart(POM, secondAppointment.element); + + expect(POM.getDraggedAppointment()?.getText()).toContain('Appointment 2'); + expect(secondAppointment.isDragSource()).toBe(true); + }); + }); + + describe('Saving appointment on drop', () => { + it('should update appointment on drop', async () => { + const onAppointmentUpdating = jest.fn(); + const onAppointmentUpdated = jest.fn(); + + const appointmentData = { + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }; + + const { POM } = await createScheduler({ + dataSource: [{ ...appointmentData }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + onAppointmentUpdating, + onAppointmentUpdated, + }); + + const appointment = POM.getAppointments()[0].element; + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, appointment); + dragMove(POM, appointment, targetCell); + dragEnd(POM, appointment); + + const expectedNewData = { + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 2), + endDate: new Date(2015, 1, 9, 3), + }; + + expect(onAppointmentUpdating).toHaveBeenCalledTimes(1); + expect(onAppointmentUpdating).toHaveBeenCalledWith( + expect.objectContaining({ + oldData: expect.objectContaining(appointmentData), + newData: expect.objectContaining(expectedNewData), + }), + ); + + expect(onAppointmentUpdated).toHaveBeenCalledTimes(1); + expect(onAppointmentUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + appointmentData: expect.objectContaining(expectedNewData), + }), + ); + }); + + it('should not update appointment if it was dropped in the same cell', async () => { + const onAppointmentUpdating = jest.fn(); + + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + onAppointmentUpdating, + }); + + const appointment = POM.getAppointments()[0].element; + const sameCell = POM.getDateTableCell(16, 0); + + document.elementsFromPoint = jest.fn(() => [sameCell]); + dragStart(POM, appointment); + dragMove(POM, appointment, sameCell); + dragEnd(POM, appointment); + + expect(onAppointmentUpdating).not.toHaveBeenCalled(); + }); + + it('should not update if dropped outside the scheduler', async () => { + const onAppointmentUpdating = jest.fn(); + + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + onAppointmentUpdating, + }); + + const appointment = POM.getAppointments()[0]; + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, appointment.element); + dragMove(POM, appointment.element, targetCell); + dragMove(POM, appointment.element); + dragEnd(POM, appointment.element); + + expect(onAppointmentUpdating).not.toHaveBeenCalled(); + expect(appointment.isDragSource()).toBe(false); + }); + + it('should update appointment dragged from tooltip', async () => { + const onAppointmentUpdating = jest.fn(); + + const { POM } = await createScheduler({ + dataSource: [ + { text: 'Apt1', startDate: new Date(2017, 4, 22, 9, 30), endDate: new Date(2017, 4, 22, 10, 30) }, + { text: 'Apt2', startDate: new Date(2017, 4, 22, 9, 30), endDate: new Date(2017, 4, 22, 10, 30) }, + { text: 'Apt3', startDate: new Date(2017, 4, 22, 9, 30), endDate: new Date(2017, 4, 22, 10, 30) }, + ], + views: [{ type: 'day', maxAppointmentsPerCell: 1 }], + currentView: 'day', + currentDate: new Date(2017, 4, 22), + editing: true, + onAppointmentUpdating, + }); + + POM.getCollectorButton().click(); + + const tooltipItem = POM.tooltip.getAppointmentItem(0); + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, tooltipItem); + dragMove(POM, tooltipItem, targetCell); + dragEnd(POM, tooltipItem); + + expect(onAppointmentUpdating).toHaveBeenCalledTimes(1); + expect(onAppointmentUpdating).toHaveBeenCalledWith( + expect.objectContaining({ + oldData: expect.objectContaining({ text: 'Apt2' }), + newData: expect.objectContaining({ + startDate: new Date(2017, 4, 22, 2), + endDate: new Date(2017, 4, 22, 3), + }), + }), + ); + }); + + it('should update appointment dragged from single appointment tooltip', async () => { + const onAppointmentUpdating = jest.fn(); + + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + onAppointmentUpdating, + }); + + jest.useFakeTimers(); + POM.getAppointments()[0].element.click(); + jest.runAllTimers(); + jest.useRealTimers(); + + expect(POM.tooltip.isVisible()).toBe(true); + + const tooltipItem = POM.tooltip.getAppointmentItem(0); + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, tooltipItem); + dragMove(POM, tooltipItem, targetCell); + dragEnd(POM, tooltipItem); + + expect(onAppointmentUpdating).toHaveBeenCalledTimes(1); + expect(onAppointmentUpdating).toHaveBeenCalledWith( + expect.objectContaining({ + oldData: expect.objectContaining({ text: 'Appointment 1' }), + newData: expect.objectContaining({ + startDate: new Date(2015, 1, 9, 2), + endDate: new Date(2015, 1, 9, 3), + }), + }), + ); + }); + + it('should correctly update appointment when dragged to all day panel', async () => { + const onAppointmentUpdated = jest.fn(); + + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + showAllDayPanel: true, + allDayPanelMode: 'allDay', + onAppointmentUpdated, + }); + + const appointment = POM.getAppointments()[0].element; + const allDayCell = POM.getAllDayTableCell(0); + + dragStart(POM, appointment); + dragMove(POM, appointment, allDayCell); + dragEnd(POM, appointment); + + expect(onAppointmentUpdated).toHaveBeenCalledTimes(1); + expect(onAppointmentUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + appointmentData: expect.objectContaining({ + text: 'Appointment 1', + allDay: true, + }), + }), + ); + }); + + it('should correctly update appointment when dragged from all day panel', async () => { + const onAppointmentUpdated = jest.fn(); + + const { POM } = await createScheduler({ + dataSource: [{ + text: 'All Day Appointment', + startDate: new Date(2015, 1, 9), + endDate: new Date(2015, 1, 9), + allDay: true, + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + showAllDayPanel: true, + allDayPanelMode: 'allDay', + onAppointmentUpdated, + }); + + const appointment = POM.getAppointments()[0].element; + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, appointment); + dragMove(POM, appointment, targetCell); + dragEnd(POM, appointment); + + expect(onAppointmentUpdated).toHaveBeenCalledTimes(1); + expect(onAppointmentUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + appointmentData: expect.objectContaining({ + text: 'All Day Appointment', + startDate: new Date(2015, 1, 9, 2), + endDate: new Date(2015, 1, 9, 2, 30), + allDay: false, + }), + }), + ); + }); + + it('should correctly update appointments if allowUpdating is async', async () => { + const onAppointmentUpdating = jest.fn((e) => { + (e as any).cancel = new Promise((resolve) => { + setTimeout(() => resolve(false), 3000); + }); + }); + const onAppointmentUpdated = jest.fn(); + + const appointmentData1 = { + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }; + const appointmentData2 = { + text: 'Appointment 2', + startDate: new Date(2015, 1, 9, 10), + endDate: new Date(2015, 1, 9, 11), + }; + + const { POM } = await createScheduler({ + dataSource: [{ ...appointmentData1 }, { ...appointmentData2 }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + onAppointmentUpdating, + onAppointmentUpdated, + }); + + jest.useFakeTimers(); + + const firstAppointment = POM.getAppointments()[0].element; + const targetCell1 = POM.getDateTableCell(4, 0); + + dragStart(POM, firstAppointment); + dragMove(POM, firstAppointment, targetCell1); + dragEnd(POM, firstAppointment); + + await jest.advanceTimersByTimeAsync(1000); + + const secondAppointment = POM.getAppointments()[1].element; + const targetCell2 = POM.getDateTableCell(6, 0); + + dragStart(POM, secondAppointment); + dragMove(POM, secondAppointment, targetCell2); + dragEnd(POM, secondAppointment); + + await jest.runAllTimersAsync(); + + expect(onAppointmentUpdating).toHaveBeenCalledTimes(2); + expect(onAppointmentUpdated).toHaveBeenCalledTimes(2); + expect(onAppointmentUpdated).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + appointmentData: expect.objectContaining({ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 2), + endDate: new Date(2015, 1, 9, 3), + }), + }), + ); + expect(onAppointmentUpdated).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + appointmentData: expect.objectContaining({ + text: 'Appointment 2', + startDate: new Date(2015, 1, 9, 3), + endDate: new Date(2015, 1, 9, 4), + }), + }), + ); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts b/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts index 31f6552ea335..d8558d8d7a37 100644 --- a/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts @@ -32,8 +32,7 @@ export interface AppointmentDragControllerOptions { updateAppointmentOnDrop: ( appointmentData: Appointment, $cell: dxElementWrapper, - event: DragEndEvent, - ) => void; + ) => Promise; } export interface WorkSpaceDraggableOptions { @@ -162,7 +161,7 @@ export class AppointmentDragController { const $cell = this.options.getCellFromDragTarget($(this.$dragClone)); if (!$cell) { - this.removeHighlight(); + this.removeCellHighlight(); return; } @@ -178,11 +177,16 @@ export class AppointmentDragController { const isSameCell = this.$initialCell?.is(this.$highlightedCell) ?? false; const isSameScheduler = this.$highlightedCell.closest(e.fromComponent.$element()).length > 0; - if (!isSameCell && isSameScheduler) { - this.options.updateAppointmentOnDrop(e.itemData, this.$highlightedCell, e); + if (isSameCell || !isSameScheduler) { + this.removeDraggingClasses($(e.itemElement)); + return; } - this.removeDraggingClasses($(e.itemElement)); + this.options.updateAppointmentOnDrop(e.itemData, this.$highlightedCell) + .then(() => { this.removeDraggingClasses($(e.itemElement)); }) + .catch(() => { }); + + this.removeCellHighlight(); } private onDragCancel(e): void { @@ -191,17 +195,16 @@ export class AppointmentDragController { public removeDraggingClasses($dragSource: dxElementWrapper): void { $dragSource.removeClass(APPOINTMENT_DRAG_SOURCE_CLASS); - this.$dragClone = null; - this.removeHighlight(); + this.removeCellHighlight(); } private highlightCell($cell: dxElementWrapper): void { - this.removeHighlight(); + this.removeCellHighlight(); $cell.addClass(HIGHLIGHTED_CELL_CLASS); this.$highlightedCell = $cell; } - private removeHighlight(): void { + private removeCellHighlight(): void { this.$highlightedCell?.removeClass(HIGHLIGHTED_CELL_CLASS); this.$highlightedCell = null; } diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index b91c1268850e..55bfe670e55a 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -28,7 +28,6 @@ import { import { hasWindow } from '@js/core/utils/window'; import DataHelperMixin from '@js/data_helper'; import { custom as customDialog } from '@js/ui/dialog'; -import type { DragEndEvent } from '@js/ui/draggable'; import type { Appointment, AppointmentTooltipShowingEvent, DayOfWeek, Occurrence, } from '@js/ui/scheduler'; @@ -1669,36 +1668,25 @@ class Scheduler extends SchedulerOptionsBaseWidget { private updateAppointmentOnDrop( appointmentData: Appointment, $cell: dxElementWrapper, - dragEvent: DragEndEvent, - ): void { + ): Promise { const updatedData = this.getUpdatedData(appointmentData, $cell); const newAppointmentData = extend({}, appointmentData, updatedData); const startDate = this._dataAccessors.get('startDate', newAppointmentData); - this.checkRecurringAppointment( - appointmentData, - newAppointmentData, - startDate, - () => { - this.updateAppointmentCore( - appointmentData, - newAppointmentData, - (): void => { - if (isDeferred(dragEvent.cancel)) { - (dragEvent.cancel as any).resolve?.(); - } else { - dragEvent.cancel = true; - } - - this.appointmentDragController.removeDraggingClasses($(dragEvent.itemElement)); - }, - dragEvent, - ); - }, - undefined, - undefined, - dragEvent, - ); + return new Promise((resolve) => { + this.checkRecurringAppointment( + appointmentData, + newAppointmentData, + startDate, + () => { + this.updateAppointmentCore( + appointmentData, + newAppointmentData, + ).done(() => resolve()); + }, + false, + ); + }); } checkRecurringAppointment( @@ -1979,7 +1967,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { dragEvent.cancel = new Deferred(); } - if (isPromise(updatingOptions.cancel) && dragEvent) { + if (isPromise(updatingOptions.cancel) && (dragEvent || this.option('_newAppointments'))) { this.updatingAppointments.add(target); } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/desktopTooltip.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/desktopTooltip.tests.js index 5326f4b556ab..b1036770423d 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/desktopTooltip.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/desktopTooltip.tests.js @@ -116,7 +116,6 @@ QUnit.test('contentTemplate passed to createComponent should work correct', asyn assert.equal(stubCreateComponent.getCall(1).args[0][0].nodeName, 'DIV'); assert.equal(stubCreateComponent.getCall(1).args[1], List); - assert.equal(Object.keys(stubCreateComponent.getCall(1).args[2]).length, 7); assert.equal(stubCreateComponent.getCall(1).args[2].dataSource, dataList); assert.equal(stubCreateComponent.getCall(1).args[2].showScrollbar, 'onHover'); assert.ok(stubCreateComponent.getCall(1).args[2].onContentReady); From eda5cf99cab32fc14adeb7277d7c51dc3ae6d400 Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Tue, 26 May 2026 18:33:00 +0800 Subject: [PATCH 3/6] fix tooltip jest tests --- .../__internal/scheduler/__tests__/__mock__/model/tooltip.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/tooltip.ts b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/tooltip.ts index 9240e55274b1..3d8bbdb2c333 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/tooltip.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/tooltip.ts @@ -7,7 +7,8 @@ const TOOLTIP_WIDGET_SELECTOR = ` .dx-scheduler-appointment-tooltip-wrapper.dx-tooltip.dx-widget `; const TOOLTIP_WRAPPER_SELECTOR = ` - .dx-scheduler-appointment-tooltip-wrapper.dx-tooltip-wrapper + .dx-scheduler-appointment-tooltip-wrapper.dx-tooltip-wrapper, + .dx-scheduler-overlay-panel.dx-overlay-wrapper `; export class TooltipModel { From f247b89da6118dbb853661f65b66d3dc830b4884 Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Tue, 26 May 2026 18:59:57 +0800 Subject: [PATCH 4/6] apply copilot's suggestion --- .../js/__internal/scheduler/appointment_drag_controller.ts | 4 ++-- packages/devextreme/js/__internal/scheduler/m_scheduler.ts | 2 +- .../js/__internal/scheduler/workspaces/m_work_space.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts b/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts index d8558d8d7a37..b1fa5743878d 100644 --- a/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts @@ -183,8 +183,8 @@ export class AppointmentDragController { } this.options.updateAppointmentOnDrop(e.itemData, this.$highlightedCell) - .then(() => { this.removeDraggingClasses($(e.itemElement)); }) - .catch(() => { }); + .finally(() => { this.removeDraggingClasses($(e.itemElement)); }) + .catch((err) => { throw err; }); this.removeCellHighlight(); } diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index 55bfe670e55a..f09f3693ac68 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -1682,7 +1682,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { this.updateAppointmentCore( appointmentData, newAppointmentData, - ).done(() => resolve()); + ).always(resolve); }, false, ); diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index 17ef16a77cdd..96cdda94fcad 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -2230,7 +2230,7 @@ class SchedulerWorkSpace extends Widget { } const point = this.getPointFromDragTarget($dragTarget); - const elements = document.elementsFromPoint(point.x, point.y); + const elements = (domAdapter as any).elementsFromPoint(point.x, point.y); const cell = elements.find((element) => element.classList.contains('dx-scheduler-date-table-cell') || element.classList.contains('dx-scheduler-all-day-table-cell')); From 5457bec6fb2d38dab6abce6d42d9f39b409fc2e8 Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Wed, 27 May 2026 12:16:19 +0800 Subject: [PATCH 5/6] apply review --- .../__tests__/appointments_dragging.test.ts | 133 ++++++++++++++++++ .../scheduler/appointment_drag_controller.ts | 34 +++-- .../appointments_new/appointments.ts | 12 +- .../js/__internal/scheduler/m_scheduler.ts | 16 ++- 4 files changed, 179 insertions(+), 16 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 193f6dd30f04..496f58f998bd 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_dragging.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_dragging.test.ts @@ -377,6 +377,40 @@ describe('Appointments Dragging', () => { expect(appointment.isDragSource()).toBe(false); }); + + it('should remove drag-source class when changing recurring appointment was cancelled', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + recurrenceRule: 'FREQ=DAILY;COUNT=5', + }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + onAppointmentUpdating: (e) => { + e.cancel = new Promise((resolve) => { + setTimeout(() => resolve(false), 3000); + }); + }, + }); + + const appointment = POM.getAppointments()[0]; + + dragStart(POM, appointment.element); + dragMove(POM, appointment.element, POM.getDateTableCell(4, 0)); + dragEnd(POM, appointment.element); + + expect(POM.isPopupVisible()).toBe(true); + expect(appointment.isDragSource()).toBe(true); + + POM.popup.closeButton.click(); + await new Promise(process.nextTick); + + expect(POM.isPopupVisible()).toBe(false); + expect(appointment.isDragSource()).toBe(false); + }); }); describe('Allow dragging', () => { @@ -813,5 +847,104 @@ describe('Appointments Dragging', () => { }), ); }); + + it('should correctly update recurring appointment when editing a single occurrence', async () => { + const onAppointmentUpdated = jest.fn(); + const onAppointmentAdded = jest.fn(); + + const appointmentData = { + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + recurrenceRule: 'FREQ=DAILY;COUNT=5', + }; + + const { POM } = await createScheduler({ + dataSource: [{ ...appointmentData }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + onAppointmentUpdated, + onAppointmentAdded, + }); + + const appointment = POM.getAppointments()[0].element; + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, appointment); + dragMove(POM, appointment, targetCell); + dragEnd(POM, appointment); + + POM.popup.editAppointmentButton.click(); + await new Promise(process.nextTick); + + expect(onAppointmentAdded).toHaveBeenCalledTimes(1); + expect(onAppointmentAdded).toHaveBeenCalledWith( + expect.objectContaining({ + appointmentData: expect.objectContaining({ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 2), + endDate: new Date(2015, 1, 9, 3), + }), + }), + ); + + const addedAppointment = (onAppointmentAdded.mock.calls[0][0] as any).appointmentData; + expect(addedAppointment.recurrenceRule).toBeUndefined(); + + expect(onAppointmentUpdated).toHaveBeenCalledTimes(1); + expect(onAppointmentUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + appointmentData: expect.objectContaining({ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + recurrenceRule: 'FREQ=DAILY;COUNT=5', + recurrenceException: '20150209T000000Z', + }), + }), + ); + }); + + it('should correctly update recurring appointment when editing the series', async () => { + const onAppointmentUpdated = jest.fn(); + + const appointmentData = { + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + recurrenceRule: 'FREQ=DAILY;COUNT=5', + }; + + const { POM } = await createScheduler({ + dataSource: [{ ...appointmentData }], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + editing: true, + onAppointmentUpdated, + }); + + const appointment = POM.getAppointments()[0].element; + const targetCell = POM.getDateTableCell(4, 0); + + dragStart(POM, appointment); + dragMove(POM, appointment, targetCell); + dragEnd(POM, appointment); + + POM.popup.editSeriesButton.click(); + await new Promise(process.nextTick); + + expect(onAppointmentUpdated).toHaveBeenCalledTimes(1); + expect(onAppointmentUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + appointmentData: expect.objectContaining({ + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 2), + endDate: new Date(2015, 1, 9, 3), + recurrenceRule: 'FREQ=DAILY;COUNT=5', + }), + }), + ); + }); }); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts b/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts index b1fa5743878d..422d2179949b 100644 --- a/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointment_drag_controller.ts @@ -9,7 +9,7 @@ import Draggable from '@ts/m_draggable'; import { APPOINTMENT_CLASSES } from './appointments_new/const'; import type Scheduler from './m_scheduler'; -import type { AppointmentTooltipItem } from './types'; +import type { AppointmentTooltipItem, TargetedAppointment } from './types'; import type { AppointmentItemViewModel } from './view_model/types'; const APPOINTMENT_DRAG_SOURCE_CLASS = 'dx-scheduler-appointment-drag-source'; @@ -31,12 +31,16 @@ export interface AppointmentDragControllerOptions { updateAppointmentOnDrop: ( appointmentData: Appointment, + targetedAppointmentData: TargetedAppointment, $cell: dxElementWrapper, ) => Promise; } export interface WorkSpaceDraggableOptions { - getAppointmentData: ($element: dxElementWrapper) => Appointment; + getAppointmentData: ($element: dxElementWrapper) => { + appointmentData: Appointment; + targetedAppointmentData: TargetedAppointment; + }; } export interface TooltipDraggableOptions { @@ -92,17 +96,17 @@ export class AppointmentDragController { $tooltipList: dxElementWrapper, draggableOptions: TooltipDraggableOptions, ): void { - let draggingTooltipItem: AppointmentTooltipItem | null = null; + let tooltipItem: AppointmentTooltipItem | null = null; const config: DraggableProperties = { ...this.getCommonDraggableConfig(), filter: `.${TOOLTIP_LIST_ITEM_CLASS}`, dragTemplate: () => { - if (!draggingTooltipItem) { + if (!tooltipItem) { return $(); } - const appointmentViewModel = draggingTooltipItem.settings; + const appointmentViewModel = tooltipItem.settings; this.$dragClone = draggableOptions.dragTemplate(appointmentViewModel); this.$dragClone.css({ top: '', left: '' }); @@ -111,8 +115,11 @@ export class AppointmentDragController { return this.$dragClone; }, onDragStart: (e: DragStartEvent) => { - draggingTooltipItem = $(e.itemElement).data('dxListItemData') as unknown as AppointmentTooltipItem; - e.itemData = draggingTooltipItem.appointment; + tooltipItem = $(e.itemElement).data('dxListItemData') as unknown as AppointmentTooltipItem; + e.itemData = { + appointmentData: tooltipItem.appointment, + targetedAppointmentData: tooltipItem.targetedAppointment ?? tooltipItem.appointment, + }; this.onDragStart(e); }, @@ -144,7 +151,7 @@ export class AppointmentDragController { } private onDragStart(e: DragStartEvent): void { - if (!this.options.canDragAppointment(e.itemData)) { + if (!this.options.canDragAppointment(e.itemData.appointmentData)) { e.cancel = true; return; } @@ -182,18 +189,23 @@ export class AppointmentDragController { return; } - this.options.updateAppointmentOnDrop(e.itemData, this.$highlightedCell) + this.options.updateAppointmentOnDrop( + e.itemData.appointmentData, + e.itemData.targetedAppointmentData, + this.$highlightedCell, + ) .finally(() => { this.removeDraggingClasses($(e.itemElement)); }) .catch((err) => { throw err; }); this.removeCellHighlight(); } - private onDragCancel(e): void { + // Note: onDragCancel is private callback, so there's no type for it + private onDragCancel(e: DragEndEvent): void { this.removeDraggingClasses($(e.itemElement)); } - public removeDraggingClasses($dragSource: dxElementWrapper): void { + private removeDraggingClasses($dragSource: dxElementWrapper): void { $dragSource.removeClass(APPOINTMENT_DRAG_SOURCE_CLASS); this.removeCellHighlight(); } diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index 50a5c8a53e20..328fba6dfa3b 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -110,12 +110,18 @@ export class Appointments extends DOMComponent item.$element().is($element), - ); + ) as BaseAppointmentView; - return (viewItem as BaseAppointmentView).appointmentData; + return { + appointmentData: viewItem.appointmentData, + targetedAppointmentData: viewItem.targetedAppointmentData, + }; } public get $allDayContainer(): dxElementWrapper | null { diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index f09f3693ac68..41d06b16a49e 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -1667,11 +1667,12 @@ class Scheduler extends SchedulerOptionsBaseWidget { private updateAppointmentOnDrop( appointmentData: Appointment, + targetedAppointmentData: TargetedAppointment, $cell: dxElementWrapper, ): Promise { const updatedData = this.getUpdatedData(appointmentData, $cell); const newAppointmentData = extend({}, appointmentData, updatedData); - const startDate = this._dataAccessors.get('startDate', newAppointmentData); + const startDate = this._dataAccessors.get('startDate', targetedAppointmentData); return new Promise((resolve) => { this.checkRecurringAppointment( @@ -1685,6 +1686,10 @@ class Scheduler extends SchedulerOptionsBaseWidget { ).always(resolve); }, false, + undefined, + undefined, + undefined, + (): void => { resolve(); }, ); }); } @@ -1698,6 +1703,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { isPopupEditing?: any, dragEvent?: any, recurrenceEditMode?: any, + onCancel?: () => void, ) { const recurrenceRule = this._dataAccessors.get('recurrenceRule', rawAppointment); @@ -1732,7 +1738,13 @@ class Scheduler extends SchedulerOptionsBaseWidget { dragEvent, ); }) - .fail(() => this._appointments.moveAppointmentBack(dragEvent)); + .fail(() => { + if (this.option('_newAppointments')) { + onCancel?.(); + } else { + this._appointments.moveAppointmentBack(dragEvent); + } + }); } } From 7481c62e254990ea92cd41188b9f78bdf8aebe93 Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Wed, 27 May 2026 13:36:34 +0800 Subject: [PATCH 6/6] fix test --- .../__tests__/appointments_dragging.test.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 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 496f58f998bd..92bd1fd13b92 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_dragging.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_dragging.test.ts @@ -854,8 +854,8 @@ describe('Appointments Dragging', () => { const appointmentData = { text: 'Appointment 1', - startDate: new Date(2015, 1, 9, 8), - endDate: new Date(2015, 1, 9, 9), + startDate: '2015-02-08T18:00:00.000Z', + endDate: '2015-02-08T19:00:00.000Z', recurrenceRule: 'FREQ=DAILY;COUNT=5', }; @@ -866,6 +866,7 @@ describe('Appointments Dragging', () => { editing: true, onAppointmentUpdated, onAppointmentAdded, + timeZone: 'UTC', }); const appointment = POM.getAppointments()[0].element; @@ -879,15 +880,13 @@ describe('Appointments Dragging', () => { await new Promise(process.nextTick); expect(onAppointmentAdded).toHaveBeenCalledTimes(1); - expect(onAppointmentAdded).toHaveBeenCalledWith( - expect.objectContaining({ - appointmentData: expect.objectContaining({ - text: 'Appointment 1', - startDate: new Date(2015, 1, 9, 2), - endDate: new Date(2015, 1, 9, 3), - }), - }), - ); + const addedAppointmentData = (onAppointmentAdded.mock.calls[0][0] as any).appointmentData; + expect(addedAppointmentData).toEqual({ + text: 'Appointment 1', + startDate: '2015-02-09T02:00:00.000Z', + endDate: '2015-02-09T03:00:00.000Z', + allDay: false, + }); const addedAppointment = (onAppointmentAdded.mock.calls[0][0] as any).appointmentData; expect(addedAppointment.recurrenceRule).toBeUndefined(); @@ -897,10 +896,10 @@ describe('Appointments Dragging', () => { expect.objectContaining({ appointmentData: expect.objectContaining({ text: 'Appointment 1', - startDate: new Date(2015, 1, 9, 8), - endDate: new Date(2015, 1, 9, 9), + startDate: '2015-02-08T18:00:00.000Z', + endDate: '2015-02-08T19:00:00.000Z', recurrenceRule: 'FREQ=DAILY;COUNT=5', - recurrenceException: '20150209T000000Z', + recurrenceException: '20150209T180000Z', }), }), );