From bf2f87d476a2afcc539ca4c15c3e9b72dd19fff7 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:26:20 +0400 Subject: [PATCH 01/16] refactor(scheduler): add resize date math to new appointments collection --- .../get_appointment_date_range.test.ts | 144 ++++++++++++++ .../resizing/get_appointment_date_range.ts | 177 ++++++++++++++++++ .../resizing/get_delta_time.test.ts | 87 +++++++++ .../resizing/get_delta_time.ts | 60 ++++++ .../appointments_new/resizing/types.ts | 48 +++++ 5 files changed, 516 insertions(+) create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.test.ts create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.ts create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_delta_time.test.ts create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_delta_time.ts create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/types.ts diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.test.ts new file mode 100644 index 000000000000..d574fb172846 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.test.ts @@ -0,0 +1,144 @@ +import { + describe, expect, it, +} from '@jest/globals'; + +import type { CellRect, DOMMetaData, ViewCellData } from '../../types'; +import type { AppointmentItemViewModel } from '../../view_model/types'; +import { getAppointmentDateRange } from './get_appointment_date_range'; +import type { GetAppointmentDateRangeOptions, Rect } from './types'; + +const CELL_WIDTH = 100; +const CELL_HEIGHT = 50; +const COLUMN_COUNT = 7; + +const mockViewDataProvider = { + getCellData: (rowIndex: number, columnIndex: number): ViewCellData => ({ + startDate: new Date(2024, 0, 1 + columnIndex), + endDate: new Date(2024, 0, 2 + columnIndex), + index: columnIndex, + } as ViewCellData), +}; + +const mockDOMMetaData = (): DOMMetaData => ({ + allDayPanelCellsMeta: Array.from({ length: COLUMN_COUNT }, (_, columnIndex): CellRect => ({ + top: 0, + left: columnIndex * CELL_WIDTH, + width: CELL_WIDTH, + height: CELL_HEIGHT, + })), + dateTableCellsMeta: [], +}); + +const createOptions = (overrides: { + handles: { left: boolean; right: boolean }; + appointmentRect: Rect; + sourceStartDate: Date; + sourceEndDate: Date; + rtlEnabled?: boolean; +}): GetAppointmentDateRangeOptions => ({ + handles: overrides.handles, + appointmentSettings: { + allDay: true, + rowIndex: 0, + columnIndex: 0, + info: { + sourceAppointment: { + startDate: overrides.sourceStartDate, + endDate: overrides.sourceEndDate, + }, + appointment: { allDay: true }, + }, + } as unknown as AppointmentItemViewModel, + isVerticalGroupedWorkSpace: false, + appointmentRect: overrides.appointmentRect, + parentAppointmentRect: { + top: 0, left: 0, width: COLUMN_COUNT * CELL_WIDTH, height: CELL_HEIGHT, + }, + viewDataProvider: mockViewDataProvider as unknown as GetAppointmentDateRangeOptions['viewDataProvider'], + rtlEnabled: overrides.rtlEnabled ?? false, + DOMMetaData: mockDOMMetaData(), +} as unknown as GetAppointmentDateRangeOptions); + +describe('getAppointmentDateRange', () => { + it('should set endDate from the last occupied cell when the right handle is dragged', () => { + const options = createOptions({ + handles: { left: false, right: true }, + appointmentRect: { + top: 0, left: CELL_WIDTH, width: CELL_WIDTH, height: CELL_HEIGHT, + }, + sourceStartDate: new Date(2024, 0, 2), + sourceEndDate: new Date(2024, 0, 4), + }); + + const range = getAppointmentDateRange(options); + + expect(range.startDate).toEqual(new Date(2024, 0, 2)); + expect(range.endDate).toEqual(new Date(2024, 0, 3)); + }); + + it('should set startDate from the first occupied cell when the left handle is dragged', () => { + const options = createOptions({ + handles: { left: true, right: false }, + appointmentRect: { + top: 0, left: 2 * CELL_WIDTH, width: CELL_WIDTH, height: CELL_HEIGHT, + }, + sourceStartDate: new Date(2024, 0, 2), + sourceEndDate: new Date(2024, 0, 4), + }); + + const range = getAppointmentDateRange(options); + + expect(range.startDate).toEqual(new Date(2024, 0, 3)); + expect(range.endDate).toEqual(new Date(2024, 0, 4)); + }); + + it('should set endDate from the last occupied cell when the right handle is dragged across several cells', () => { + const options = createOptions({ + handles: { left: false, right: true }, + appointmentRect: { + top: 0, left: CELL_WIDTH, width: 2 * CELL_WIDTH, height: CELL_HEIGHT, + }, + sourceStartDate: new Date(2024, 0, 2), + sourceEndDate: new Date(2024, 0, 4), + }); + + const range = getAppointmentDateRange(options); + + expect(range.startDate).toEqual(new Date(2024, 0, 2)); + expect(range.endDate).toEqual(new Date(2024, 0, 4)); + }); + + it('should set startDate from the first occupied cell when the right handle is dragged in RTL', () => { + const options = createOptions({ + handles: { left: false, right: true }, + appointmentRect: { + top: 0, left: CELL_WIDTH, width: CELL_WIDTH, height: CELL_HEIGHT, + }, + sourceStartDate: new Date(2024, 0, 2), + sourceEndDate: new Date(2024, 0, 4), + rtlEnabled: true, + }); + + const range = getAppointmentDateRange(options); + + expect(range.startDate).toEqual(new Date(2024, 0, 2)); + expect(range.endDate).toEqual(new Date(2024, 0, 4)); + }); + + it('should set endDate from the last occupied cell when the left handle is dragged in RTL', () => { + const options = createOptions({ + handles: { left: true, right: false }, + appointmentRect: { + top: 0, left: CELL_WIDTH, width: CELL_WIDTH, height: CELL_HEIGHT, + }, + sourceStartDate: new Date(2024, 0, 2), + sourceEndDate: new Date(2024, 0, 4), + rtlEnabled: true, + }); + + const range = getAppointmentDateRange(options); + + expect(range.startDate).toEqual(new Date(2024, 0, 2)); + expect(range.endDate).toEqual(new Date(2024, 0, 3)); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.ts new file mode 100644 index 000000000000..4a1401ca0afd --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.ts @@ -0,0 +1,177 @@ +import { dateUtilsTs } from '@ts/core/utils/date'; +import { dateUtils } from '@ts/core/utils/m_date'; + +import type { ViewCellData } from '../../types'; +import type { + CellsInfo, + DateRange, + GetAppointmentDateRangeOptions, + GetAppointmentDateRangeOptionsExtended, + Rect, +} from './types'; + +const toMs = dateUtils.dateToMilliseconds; + +const getCellData = ( + { viewDataProvider }: GetAppointmentDateRangeOptionsExtended, + cellRowIndex: number, + cellColumnIndex: number, + isOccupiedAllDay: boolean, + isAllDay = false, + rtlEnabled = false, +): ViewCellData => { + const cellData = viewDataProvider.getCellData( + cellRowIndex, + cellColumnIndex, + isOccupiedAllDay, + rtlEnabled, + ); + + if (!isAllDay) { + cellData.endDate = dateUtilsTs.addOffsets(cellData.startDate, toMs('day')); + } + + return cellData; +}; + +const getAppointmentLeftCell = (options: GetAppointmentDateRangeOptionsExtended): ViewCellData => { + const { + cellHeight, + cellWidth, + relativeAppointmentRect, + appointment, + rtlEnabled, + } = options; + + const cellRowIndex = Math.floor(relativeAppointmentRect.top / cellHeight); + const cellColumnIndex = Math.round(relativeAppointmentRect.left / cellWidth); + + return getCellData( + options, + cellRowIndex, + cellColumnIndex, + appointment.isOccupiedAllDay, + appointment.isAllDay, + rtlEnabled, + ); +}; + +const getResizedDateRange = (options: GetAppointmentDateRangeOptionsExtended): DateRange => { + const { + rtlEnabled, + handles, + appointment, + relativeAppointmentRect, + cellWidth, + cellCountInRow, + } = options; + + const leftCell = getAppointmentLeftCell(options); + const cellsAmount = Math.round(relativeAppointmentRect.width / cellWidth); + const isStartEdgeResized = rtlEnabled ? handles.right : handles.left; + + if (isStartEdgeResized) { + const firstCell = rtlEnabled + ? getCellData( + options, + Math.floor(leftCell.index / cellCountInRow), + leftCell.index - cellsAmount + 1, + appointment.isOccupiedAllDay, + appointment.isAllDay, + ) + : leftCell; + + return { + startDate: firstCell.startDate, + endDate: firstCell.startDate > appointment.endDate + ? firstCell.startDate + : appointment.endDate, + }; + } + + const lastCellIndex = leftCell.index + cellsAmount - 1; + const lastCell = rtlEnabled + ? leftCell + : getCellData( + options, + Math.floor(lastCellIndex / cellCountInRow), + lastCellIndex % cellCountInRow, + appointment.isOccupiedAllDay, + appointment.isAllDay, + ); + + return { + startDate: lastCell.endDate < appointment.startDate + ? lastCell.endDate + : appointment.startDate, + endDate: lastCell.endDate, + }; +}; + +const getRelativeAppointmentRect = (appointmentRect: Rect, parentAppointmentRect: Rect): Rect => { + const left = appointmentRect.left - parentAppointmentRect.left; + const top = appointmentRect.top - parentAppointmentRect.top; + const width = left < 0 + ? appointmentRect.width + left + : appointmentRect.width; + const height = top < 0 + ? appointmentRect.height + top + : appointmentRect.height; + + return { + left: Math.max(0, left), + top: Math.max(0, top), + width, + height, + }; +}; + +const getAppointmentCellsInfo = (options: GetAppointmentDateRangeOptions): CellsInfo => { + const { + appointmentSettings, + isVerticalGroupedWorkSpace, + DOMMetaData, + } = options; + + const DOMMetaTable = appointmentSettings.allDay && !isVerticalGroupedWorkSpace + ? [DOMMetaData.allDayPanelCellsMeta] + : DOMMetaData.dateTableCellsMeta; + + const { + height: cellHeight, + width: cellWidth, + } = DOMMetaTable[appointmentSettings.rowIndex][appointmentSettings.columnIndex]; + const cellCountInRow = DOMMetaTable[appointmentSettings.rowIndex].length; + + return { + cellWidth, + cellHeight, + cellCountInRow, + }; +}; + +export const getAppointmentDateRange = (options: GetAppointmentDateRangeOptions): DateRange => { + const { + appointmentSettings, + } = options; + + const relativeAppointmentRect = getRelativeAppointmentRect( + options.appointmentRect, + options.parentAppointmentRect, + ); + const cellInfo = getAppointmentCellsInfo(options); + const appointment = { + startDate: appointmentSettings.info.sourceAppointment.startDate, + endDate: appointmentSettings.info.sourceAppointment.endDate, + isAllDay: Boolean(appointmentSettings.info.appointment.allDay), + isOccupiedAllDay: Boolean(appointmentSettings.allDay), + }; + const extendedOptions = { + ...options, + ...cellInfo, + appointment, + relativeAppointmentRect, + }; + + return getResizedDateRange(extendedOptions); +}; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_delta_time.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_delta_time.test.ts new file mode 100644 index 000000000000..e07a51c9abf0 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_delta_time.test.ts @@ -0,0 +1,87 @@ +import { + describe, expect, it, +} from '@jest/globals'; + +import { VIEW_TYPES } from '../../utils/options/constants_view'; +import type { ViewType } from '../../utils/options/types'; +import { getDeltaTime } from './get_delta_time'; + +describe('getDeltaTime', () => { + VIEW_TYPES.forEach((view) => { + it(`should return zero for not resized appointment in ${view} view`, () => { + expect(getDeltaTime( + { width: 100, height: 100 }, + { width: 100, height: 100 }, + { + viewType: view, + cellSize: { width: 50, height: 50 }, + resizableStep: 50, + cellDurationInMinutes: 30, + isAllDayPanel: true, + }, + )).toBe(0); + }); + }); + + ['day', 'week', 'workWeek'].forEach((view) => { + it(`should return correct delta in px for resized appointment in vertical ${view} view`, () => { + expect(getDeltaTime( + { width: 100, height: 50 }, + { width: 100, height: 100 }, + { + viewType: view as ViewType, + cellSize: { width: 50, height: 50 }, + resizableStep: 50, + cellDurationInMinutes: 30, + isAllDayPanel: false, + }, + )).toBe(-30 * 60_000); + }); + + it(`should return correct delta in px for resized all day appointment in vertical ${view} view`, () => { + expect(getDeltaTime( + { width: 50, height: 100 }, + { width: 100, height: 100 }, + { + viewType: view as ViewType, + cellSize: { width: 50, height: 50 }, + resizableStep: 50, + cellDurationInMinutes: 30, + isAllDayPanel: true, + }, + )).toBe(-24 * 3600_000); + }); + }); + + ['timelineMonth', 'month'].forEach((view) => { + it(`should return correct delta in px for resized appointment in ${view} view`, () => { + expect(getDeltaTime( + { width: 50, height: 100 }, + { width: 100, height: 100 }, + { + viewType: view as ViewType, + cellSize: { width: 50, height: 50 }, + resizableStep: 50, + cellDurationInMinutes: 30, + isAllDayPanel: false, + }, + )).toBe(-24 * 3600_000); + }); + }); + + ['timelineDay', 'timelineWeek', 'timelineWorkWeek'].forEach((view) => { + it(`should return zero for not resized appointment in horizontal ${view} view`, () => { + expect(getDeltaTime( + { width: 50, height: 100 }, + { width: 100, height: 100 }, + { + viewType: view as ViewType, + cellSize: { width: 50, height: 50 }, + resizableStep: 50, + cellDurationInMinutes: 30, + isAllDayPanel: false, + }, + )).toBe(-30 * 60_000); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_delta_time.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_delta_time.ts new file mode 100644 index 000000000000..0c4768c18827 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_delta_time.ts @@ -0,0 +1,60 @@ +import dateUtils from '@js/core/utils/date'; + +import { VERTICAL_VIEW_TYPES } from '../../constants'; +import type { ViewType } from '../../types'; + +const toMs = dateUtils.dateToMilliseconds; + +interface Size { + width: number; + height: number; +} +interface Options { + viewType: ViewType; + cellSize: Size; + cellDurationInMinutes: number; + resizableStep: number; + isAllDayPanel: boolean; +} + +const MIN_RESIZABLE_STEP = 2; +const getAllDayDeltaWidth = (args: Size, initialSize: Size, resizableStep: number): number => { + const intervalWidth = resizableStep || MIN_RESIZABLE_STEP; + const initialWidth = initialSize.width; + + return Math.round((args.width - initialWidth) / intervalWidth); +}; +const getHorizontalDeltaTime = (args: Size, initialSize: Size, { + cellSize, + cellDurationInMinutes, +}: Options): number => { + const deltaWidth = args.width - initialSize.width; + const deltaTime = toMs('minute') * Math.round((deltaWidth * cellDurationInMinutes) / cellSize.width); + return deltaTime; +}; +const getVerticalDeltaTime = (args: Size, initialSize: Size, { + cellSize, + cellDurationInMinutes, +}: Options): number => { + const deltaHeight = args.height - initialSize.height; + const deltaTime = toMs('minute') * Math.round((deltaHeight * cellDurationInMinutes) / cellSize.height); + return deltaTime; +}; + +export const getDeltaTime = ( + args: Size, + initialSize: Size, + options: Options, +): number => { + const { viewType, resizableStep, isAllDayPanel } = options; + switch (true) { + case ['timelineMonth', 'month'].includes(viewType) || Boolean(isAllDayPanel): + return getAllDayDeltaWidth(args, initialSize, resizableStep) * toMs('day'); + case viewType === 'agenda': + return 0; + case VERTICAL_VIEW_TYPES.includes(viewType) && !isAllDayPanel: + return getVerticalDeltaTime(args, initialSize, options); + default: + return getHorizontalDeltaTime(args, initialSize, options); + } +}; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/types.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/types.ts new file mode 100644 index 000000000000..350526fa2ce7 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/types.ts @@ -0,0 +1,48 @@ +import type { TimeZoneCalculator } from '../../r1/timezone_calculator'; +import type { DOMMetaData, ViewDataProviderType } from '../../types'; +import type { AppointmentDataAccessor } from '../../utils/data_accessor/appointment_data_accessor'; +import type { AppointmentItemViewModel } from '../../view_model/types'; + +export type Rect = Pick; + +export interface GetAppointmentDateRangeOptions { + handles: { + left: boolean; + right: boolean; + }; + appointmentSettings: AppointmentItemViewModel; + isVerticalGroupedWorkSpace: boolean; + appointmentRect: Rect; + parentAppointmentRect: Rect; + viewDataProvider: ViewDataProviderType; + isDateAndTimeView: boolean; + startDayHour: number; + endDayHour: number; + timeZoneCalculator: TimeZoneCalculator; + dataAccessors: AppointmentDataAccessor; + rtlEnabled?: boolean; + DOMMetaData: DOMMetaData; + viewOffset: number; +} + +export interface CellsInfo { + cellWidth: number; + cellHeight: number; + cellCountInRow: number; +} + +export type GetAppointmentDateRangeOptionsExtended = Omit + & CellsInfo & { + relativeAppointmentRect: Rect; + appointment: { + startDate: Date; + endDate: Date; + isAllDay: boolean; + isOccupiedAllDay: boolean; + }; + }; + +export interface DateRange { + startDate: Date; + endDate: Date; +} From 4044279a502017071097d5826dff3109e12d17c2 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:21:59 +0400 Subject: [PATCH 02/16] feat(resizable): add onCancelByEsc to cancel resize via Escape --- .../__internal/ui/resizable/resizable.test.ts | 105 ++++++++++++++++++ .../js/__internal/ui/resizable/resizable.ts | 62 +++++++++++ 2 files changed, 167 insertions(+) create mode 100644 packages/devextreme/js/__internal/ui/resizable/resizable.test.ts diff --git a/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts b/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts new file mode 100644 index 000000000000..eb5a658d4ef8 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts @@ -0,0 +1,105 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import eventsEngine from '@js/common/core/events/core/events_engine'; +import $ from '@js/core/renderer'; + +import Resizable from './resizable'; + +const HANDLE_BOTTOM_CLASS = 'dx-resizable-handle-bottom'; + +const createResizable = (options: object = {}): { + instance: Resizable; + $element: ReturnType; + $handle: ReturnType; +} => { + const $element = $('
').appendTo(document.body); + // @ts-expect-error DOMComponent constructor is not typed for direct instantiation + const instance = new Resizable($element, options); + const $handle = $element.find(`.${HANDLE_BOTTOM_CLASS}`); + + return { instance, $element, $handle }; +}; + +const startResize = ($handle: ReturnType): void => { + eventsEngine.trigger($handle, { type: 'dxdragstart', target: $handle.get(0) }); +}; + +const moveResize = ($handle: ReturnType): void => { + eventsEngine.trigger($handle, { type: 'dxdrag', target: $handle.get(0), offset: { x: 0, y: 10 } }); +}; + +const endResize = ($handle: ReturnType): void => { + eventsEngine.trigger($handle, { type: 'dxdragend', target: $handle.get(0) }); +}; + +const pressEscape = ($element: ReturnType): void => { + eventsEngine.trigger($element, { type: 'keydown', key: 'Escape' }); +}; + +describe('Resizable onCancelByEsc', () => { + beforeEach(() => { + $('
').addClass('root').appendTo(document.body); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should fire onResizeCancel when Escape is pressed during resize', () => { + const onResizeCancel = jest.fn(); + const { $element, $handle } = createResizable({ onCancelByEsc: true, onResizeCancel }); + + startResize($handle); + moveResize($handle); + pressEscape($element); + + expect(onResizeCancel).toHaveBeenCalledTimes(1); + }); + + it('should not fire onResizeEnd when resize is cancelled by Escape', () => { + const onResizeEnd = jest.fn(); + const { $element, $handle } = createResizable({ onCancelByEsc: true, onResizeEnd }); + + startResize($handle); + moveResize($handle); + pressEscape($element); + endResize($handle); + + expect(onResizeEnd).not.toHaveBeenCalled(); + }); + + it('should fire onResizeEnd normally when resize is completed without Escape', () => { + const onResizeEnd = jest.fn(); + const { $handle } = createResizable({ onCancelByEsc: true, onResizeEnd }); + + startResize($handle); + moveResize($handle); + endResize($handle); + + expect(onResizeEnd).toHaveBeenCalledTimes(1); + }); + + it('should ignore Escape when onCancelByEsc is disabled', () => { + const onResizeCancel = jest.fn(); + const onResizeEnd = jest.fn(); + const { $element, $handle } = createResizable({ onResizeCancel, onResizeEnd }); + + startResize($handle); + moveResize($handle); + pressEscape($element); + endResize($handle); + + expect(onResizeCancel).not.toHaveBeenCalled(); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + }); + + it('should not fire onResizeCancel when Escape is pressed without an active resize', () => { + const onResizeCancel = jest.fn(); + const { $element } = createResizable({ onCancelByEsc: true, onResizeCancel }); + + pressEscape($element); + + expect(onResizeCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/resizable/resizable.ts b/packages/devextreme/js/__internal/ui/resizable/resizable.ts index 07dc3a1af48b..3c4dc97a06bc 100644 --- a/packages/devextreme/js/__internal/ui/resizable/resizable.ts +++ b/packages/devextreme/js/__internal/ui/resizable/resizable.ts @@ -38,6 +38,9 @@ const RESIZABLE_HANDLE_CORNER_CLASS = 'dx-resizable-handle-corner'; const DRAGSTART_START_EVENT_NAME = addNamespace(dragEventStart, RESIZABLE); const DRAGSTART_EVENT_NAME = addNamespace(dragEventMove, RESIZABLE); const DRAGSTART_END_EVENT_NAME = addNamespace(dragEventEnd, RESIZABLE); +const KEYDOWN_EVENT_NAME = addNamespace('keydown', RESIZABLE); + +const ESCAPE_KEY = 'Escape'; const SIDE_BORDER_WIDTH_STYLES: Record = { left: 'borderLeftWidth', @@ -85,6 +88,10 @@ export interface ResizableProperties extends Properties { step?: string; roundStepValue?: boolean; + + onCancelByEsc?: boolean; + + onResizeCancel?: (e: Record) => void; } class Resizable extends DOMComponent { @@ -110,10 +117,13 @@ class Resizable extends DOMComponent { _resizeAction?: (e: Record) => void; + _resizeCancelAction?: (e: Record) => void; + _getDefaultOptions(): ResizableProperties { return { ...super._getDefaultOptions(), handles: 'all', + onCancelByEsc: false, // NOTE: does not affect proportional resize step: '1', stepPrecision: 'simple', @@ -145,6 +155,7 @@ class Resizable extends DOMComponent { this._resizeStartAction = this._createActionByOption('onResizeStart'); this._resizeEndAction = this._createActionByOption('onResizeEnd'); this._resizeAction = this._createActionByOption('onResize'); + this._resizeCancelAction = this._createActionByOption('onResizeCancel'); } _renderHandles(): void { @@ -204,12 +215,18 @@ class Resizable extends DOMComponent { immediate: true, }); }); + + if (this.option('onCancelByEsc')) { + eventsEngine.on(this.$element(), KEYDOWN_EVENT_NAME, this._keydownHandler.bind(this)); + } } _detachEventHandlers(): void { this._handles.forEach((handleElement) => { eventsEngine.off(handleElement); }); + + eventsEngine.off(this.$element(), KEYDOWN_EVENT_NAME); } _toggleEventHandlers(shouldAttachEvents: boolean | undefined): void { @@ -263,6 +280,10 @@ class Resizable extends DOMComponent { this.$element().toggleClass(RESIZABLE_RESIZING_CLASS, value); } + _isResizing(): boolean { + return this.$element().hasClass(RESIZABLE_RESIZING_CLASS); + } + _renderDragOffsets(e: Cancelable & DxEvent & { maxLeftOffset?: number; maxRightOffset?: number; @@ -437,6 +458,10 @@ class Resizable extends DOMComponent { } _dragHandler(e: DragEvent): void { + if (!this._isResizing()) { + return; + } + const offset = this._getOffset(e); const delta = this._getDeltaByOffset(offset); @@ -673,6 +698,10 @@ class Resizable extends DOMComponent { } _dragEndHandler(e: DxEvent): void { + if (!this._isResizing()) { + return; + } + const $element = this.$element(); this._resizeEndAction?.({ @@ -685,6 +714,34 @@ class Resizable extends DOMComponent { this._toggleResizingClass(false); } + _keydownHandler(e: DxEvent): void { + if (this._isResizing() && e.key === ESCAPE_KEY) { + this._cancelResize(e); + } + } + + _cancelResize(e: DxEvent): void { + this._restoreSize(); + + this._toggleResizingClass(false); + + this._resizeCancelAction?.({ + event: e, + width: this._elementSize.width, + height: this._elementSize.height, + handles: this._movingSides, + }); + } + + _restoreSize(): void { + this.option({ + width: this._elementSize.width, + height: this._elementSize.height, + }); + + move(this.$element(), this._elementLocation); + } + _renderWidth(width: number): void { const { minWidth, maxWidth } = this.option(); this.option('width', fitIntoRange(width, minWidth, maxWidth)); @@ -721,8 +778,13 @@ class Resizable extends DOMComponent { case 'onResize': case 'onResizeStart': case 'onResizeEnd': + case 'onResizeCancel': this._renderActions(); break; + case 'onCancelByEsc': + this._detachEventHandlers(); + this._attachEventHandlers(); + break; case 'area': case 'stepPrecision': case 'step': From 5c9677e1360e6df6598a4a0a048e54b2f225d6d5 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:02:27 +0400 Subject: [PATCH 03/16] refactor(scheduler): add resize computation helpers for new appointments --- .../resizing/get_resizable_config.test.ts | 62 +++++++ .../resizing/get_resizable_config.ts | 70 ++++++++ .../resizing/get_resized_dates.test.ts | 99 +++++++++++ .../resizing/get_resized_dates.ts | 163 ++++++++++++++++++ 4 files changed, 394 insertions(+) create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resizable_config.test.ts create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resizable_config.ts create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.test.ts create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.ts diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resizable_config.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resizable_config.test.ts new file mode 100644 index 000000000000..94b9f110c87b --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resizable_config.test.ts @@ -0,0 +1,62 @@ +import { + describe, expect, it, +} from '@jest/globals'; + +import type { GetResizableConfigOptions } from './get_resizable_config'; +import { getResizableConfig } from './get_resizable_config'; + +const baseOptions: GetResizableConfigOptions = { + direction: 'vertical', + cellWidth: 100, + cellHeight: 50, + resizableStep: 75, + reduced: undefined, + isGroupedByDate: false, + rtlEnabled: false, +}; + +describe('getResizableConfig', () => { + it('should build a vertical rule with top/bottom handles and cell-height step', () => { + const rule = getResizableConfig({ ...baseOptions, direction: 'vertical' }); + + expect(rule.handles).toBe('top bottom'); + expect(rule.minWidth).toBe(0); + expect(rule.minHeight).toBe(50); + expect(rule.step).toBe('50'); + expect(rule.roundStepValue).toBe(true); + }); + + it('should build a horizontal rule with left/right handles and resizable step', () => { + const rule = getResizableConfig({ ...baseOptions, direction: 'horizontal' }); + + expect(rule.handles).toBe('left right'); + expect(rule.minWidth).toBe(100); + expect(rule.minHeight).toBe(0); + expect(rule.step).toBe('75'); + expect(rule.roundStepValue).toBe(false); + }); + + it('should use strict step precision when not grouped by date', () => { + const notGrouped = getResizableConfig({ ...baseOptions, isGroupedByDate: false }); + const grouped = getResizableConfig({ ...baseOptions, isGroupedByDate: true }); + + expect(notGrouped.stepPrecision).toBe('strict'); + expect(grouped.stepPrecision).toBeUndefined(); + }); + + it.each([ + { reduced: 'head', rtlEnabled: false, expected: 'left' }, + { reduced: 'head', rtlEnabled: true, expected: 'right' }, + { reduced: 'tail', rtlEnabled: false, expected: 'right' }, + { reduced: 'tail', rtlEnabled: true, expected: 'left' }, + { reduced: 'body', rtlEnabled: false, expected: '' }, + ] as const)('should set handles for reduced=$reduced rtl=$rtlEnabled', ({ + reduced, rtlEnabled, expected, + }) => { + const rule = getResizableConfig({ + ...baseOptions, direction: 'horizontal', reduced, rtlEnabled, + }); + + expect(rule.handles).toBe(expected); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resizable_config.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resizable_config.ts new file mode 100644 index 000000000000..09c6f8a591ae --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resizable_config.ts @@ -0,0 +1,70 @@ +export type ResizeDirection = 'vertical' | 'horizontal'; + +export type ReducedPart = 'head' | 'body' | 'tail' | undefined; + +export interface GetResizableConfigOptions { + direction: ResizeDirection; + cellWidth: number; + cellHeight: number; + resizableStep: number; + reduced: ReducedPart; + isGroupedByDate: boolean; + rtlEnabled: boolean; +} + +export interface ResizableRule { + handles: string; + minWidth: number; + minHeight: number; + step: string; + roundStepValue: boolean; + stepPrecision?: string; +} + +const HORIZONTAL_HANDLES = 'left right'; +const VERTICAL_HANDLES = 'top bottom'; + +const getReducedHandles = (reduced: ReducedPart, rtlEnabled: boolean): string => { + switch (reduced) { + case 'head': + return rtlEnabled ? 'right' : 'left'; + case 'tail': + return rtlEnabled ? 'left' : 'right'; + case 'body': + return ''; + default: + return HORIZONTAL_HANDLES; + } +}; + +const getHorizontalRule = (options: GetResizableConfigOptions): ResizableRule => ({ + handles: getReducedHandles(options.reduced, options.rtlEnabled), + minWidth: options.cellWidth, + minHeight: 0, + step: String(options.resizableStep), + roundStepValue: false, +}); + +const getVerticalRule = (options: GetResizableConfigOptions): ResizableRule => { + const height = Math.round(options.cellHeight); + + return { + handles: VERTICAL_HANDLES, + minWidth: 0, + minHeight: height, + step: String(height), + roundStepValue: true, + }; +}; + +export const getResizableConfig = (options: GetResizableConfigOptions): ResizableRule => { + const rule = options.direction === 'vertical' + ? getVerticalRule(options) + : getHorizontalRule(options); + + if (!options.isGroupedByDate) { + rule.stepPrecision = 'strict'; + } + + return rule; +}; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.test.ts new file mode 100644 index 000000000000..453ec87c35d7 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.test.ts @@ -0,0 +1,99 @@ +import { + describe, expect, it, +} from '@jest/globals'; + +import { getResizedDates, isStartDateResized } from './get_resized_dates'; + +const HOUR = 60 * 60 * 1000; + +describe('isStartDateResized', () => { + it('should use the top handle for vertical timed appointments', () => { + expect(isStartDateResized({ + handles: { top: true, left: false, right: false }, + isVerticalDirection: true, + isAllDay: false, + rtlEnabled: false, + })).toBe(true); + + expect(isStartDateResized({ + handles: { top: false, left: false, right: false }, + isVerticalDirection: true, + isAllDay: false, + rtlEnabled: false, + })).toBe(false); + }); + + it('should use the left handle for horizontal appointments', () => { + expect(isStartDateResized({ + handles: { top: false, left: true, right: false }, + isVerticalDirection: false, + isAllDay: false, + rtlEnabled: false, + })).toBe(true); + }); + + it('should use the right handle for horizontal appointments in RTL', () => { + expect(isStartDateResized({ + handles: { top: false, left: false, right: true }, + isVerticalDirection: false, + isAllDay: false, + rtlEnabled: true, + })).toBe(true); + }); + + it('should use the left/right handle for all-day appointments regardless of direction', () => { + expect(isStartDateResized({ + handles: { top: true, left: true, right: false }, + isVerticalDirection: true, + isAllDay: true, + rtlEnabled: false, + })).toBe(true); + }); +}); + +describe('getResizedDates', () => { + it('should move the end date by the delta when the end is resized', () => { + const range = getResizedDates({ + startDate: new Date(2024, 0, 1, 10, 0), + endDate: new Date(2024, 0, 1, 11, 0), + deltaTime: HOUR, + isStartDateChanged: false, + needCorrectDates: false, + startDayHour: 0, + endDayHour: 24, + }); + + expect(range.startDate).toEqual(new Date(2024, 0, 1, 10, 0)); + expect(range.endDate).toEqual(new Date(2024, 0, 1, 12, 0)); + }); + + it('should move the start date by the delta when the start is resized', () => { + const range = getResizedDates({ + startDate: new Date(2024, 0, 1, 10, 0), + endDate: new Date(2024, 0, 1, 11, 0), + deltaTime: HOUR, + isStartDateChanged: true, + needCorrectDates: false, + startDayHour: 0, + endDayHour: 24, + }); + + expect(range.startDate).toEqual(new Date(2024, 0, 1, 9, 0)); + expect(range.endDate).toEqual(new Date(2024, 0, 1, 11, 0)); + }); + + it('should wrap the end date to the next visible day when delta exceeds the working hours', () => { + const range = getResizedDates({ + startDate: new Date(2024, 0, 1, 9, 0), + endDate: new Date(2024, 0, 1, 17, 0), + deltaTime: 2 * HOUR, + isStartDateChanged: false, + needCorrectDates: true, + startDayHour: 9, + endDayHour: 18, + }); + + expect(range.startDate).toEqual(new Date(2024, 0, 1, 9, 0)); + expect(range.endDate).toEqual(new Date(2024, 0, 2, 10, 0)); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.ts new file mode 100644 index 000000000000..347c7dc72b99 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.ts @@ -0,0 +1,163 @@ +import dateUtils from '@js/core/utils/date'; + +import timeZoneUtils from '../../utils_time_zone'; +import type { DateRange } from './types'; + +const toMs = dateUtils.dateToMilliseconds; + +interface ResizeHandles { + top: boolean; + left: boolean; + right: boolean; +} + +export interface IsStartDateResizedOptions { + handles: ResizeHandles; + isVerticalDirection: boolean; + isAllDay: boolean; + rtlEnabled: boolean; +} + +export interface GetResizedDatesOptions { + startDate: Date; + endDate: Date; + deltaTime: number; + isStartDateChanged: boolean; + needCorrectDates: boolean; + startDayHour: number; + endDayHour: number; +} + +export const isStartDateResized = ({ + handles, + isVerticalDirection, + isAllDay, + rtlEnabled, +}: IsStartDateResizedOptions): boolean => { + const usesHorizontalHandles = !isVerticalDirection || isAllDay; + + if (usesHorizontalHandles) { + return rtlEnabled ? handles.right : handles.left; + } + + return handles.top; +}; + +const correctEndDateByDelta = ( + endDate: Date, + deltaTime: number, + startDayHour: number, + endDayHour: number, +): number => { + const maxDate = new Date(endDate); + const minDate = new Date(endDate); + const correctEndDate = new Date(endDate); + + minDate.setHours(startDayHour, 0, 0, 0); + maxDate.setHours(endDayHour, 0, 0, 0); + + if (correctEndDate > maxDate) { + correctEndDate.setHours(endDayHour, 0, 0, 0); + } + + let result = correctEndDate.getTime() + deltaTime; + const visibleDayDuration = (endDayHour - startDayHour) * toMs('hour'); + + const daysCount = deltaTime > 0 + ? Math.ceil(deltaTime / visibleDayDuration) + : Math.floor(deltaTime / visibleDayDuration); + + if (result > maxDate.getTime() || result <= minDate.getTime()) { + const tailOfCurrentDay = maxDate.getTime() - correctEndDate.getTime(); + const tailOfPrevDays = deltaTime - tailOfCurrentDay; + const correctedEndDate = new Date(correctEndDate).setDate(correctEndDate.getDate() + daysCount); + const lastDay = new Date(correctedEndDate); + lastDay.setHours(startDayHour, 0, 0, 0); + + result = lastDay.getTime() + tailOfPrevDays - visibleDayDuration * (daysCount - 1); + } + + return result; +}; + +const correctStartDateByDelta = ( + startDate: Date, + deltaTime: number, + startDayHour: number, + endDayHour: number, +): number => { + const maxDate = new Date(startDate); + const minDate = new Date(startDate); + const correctStartDate = new Date(startDate); + + minDate.setHours(startDayHour, 0, 0, 0); + maxDate.setHours(endDayHour, 0, 0, 0); + + if (correctStartDate < minDate) { + correctStartDate.setHours(startDayHour, 0, 0, 0); + } + + let result = correctStartDate.getTime() - deltaTime; + + const visibleDayDuration = (endDayHour - startDayHour) * toMs('hour'); + + const daysCount = deltaTime > 0 + ? Math.ceil(deltaTime / visibleDayDuration) + : Math.floor(deltaTime / visibleDayDuration); + + if (result < minDate.getTime() || result >= maxDate.getTime()) { + const tailOfCurrentDay = correctStartDate.getTime() - minDate.getTime(); + const tailOfPrevDays = deltaTime - tailOfCurrentDay; + + const firstDay = new Date(correctStartDate.setDate(correctStartDate.getDate() - daysCount)); + firstDay.setHours(endDayHour, 0, 0, 0); + + result = firstDay.getTime() - tailOfPrevDays + visibleDayDuration * (daysCount - 1); + } + + return result; +}; + +export const getResizedDates = (options: GetResizedDatesOptions): DateRange => { + const { + startDate, + endDate, + deltaTime, + isStartDateChanged, + needCorrectDates, + startDayHour, + endDayHour, + } = options; + + if (isStartDateChanged) { + const correctedStart = needCorrectDates + ? correctStartDateByDelta(startDate, deltaTime, startDayHour, endDayHour) + : startDate.getTime() - deltaTime; + const startTime = correctedStart + timeZoneUtils.getTimezoneOffsetChangeInMs( + startDate, + endDate, + new Date(correctedStart), + endDate, + ); + + return { + startDate: new Date(startTime), + endDate: new Date(endDate.getTime()), + }; + } + + const correctedEnd = needCorrectDates + ? correctEndDateByDelta(endDate, deltaTime, startDayHour, endDayHour) + : endDate.getTime() + deltaTime; + const endTime = correctedEnd - timeZoneUtils.getTimezoneOffsetChangeInMs( + startDate, + endDate, + startDate, + new Date(correctedEnd), + ); + + return { + startDate: new Date(startDate.getTime()), + endDate: new Date(endTime), + }; +}; From 901090bf84db4b59848cdedee5eaf1fdc8a82bb6 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:15:18 +0400 Subject: [PATCH 04/16] feat(scheduler): render resizable handles on new grid appointments --- .../appointment/grid_appointment.test.ts | 22 +++++++++++++++++++ .../appointment/grid_appointment.ts | 13 +++++++++++ .../appointments_new/appointments.test.ts | 1 + .../appointments_new/appointments.ts | 10 +++++++++ 4 files changed, 46 insertions(+) diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.test.ts index 1ef9ec558873..a7c8581dda85 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.test.ts @@ -206,6 +206,28 @@ describe('GridAppointment', () => { }); }); + describe('Resize', () => { + it('should render resize handles when allowResize is true', async () => { + const instance = await createGridAppointment({ + ...getProperties(defaultAppointmentData), + allowResize: true, + resizableConfig: { handles: 'top bottom' }, + }); + + expect(instance.$element().find('.dx-resizable-handle').length).toBeGreaterThan(0); + }); + + it('should not render resize handles when allowResize is false', async () => { + const instance = await createGridAppointment({ + ...getProperties(defaultAppointmentData), + allowResize: false, + resizableConfig: { handles: 'top bottom' }, + }); + + expect(instance.$element().find('.dx-resizable-handle').length).toBe(0); + }); + }); + describe('Resources', () => { it('should apply resource color', async () => { const instance = await createGridAppointment({ diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.ts index 2956d8ed9cd5..223bcd2bc740 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.ts @@ -1,5 +1,6 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; +import Resizable, { type ResizableProperties } from '@ts/ui/resizable/resizable'; import { ALL_DAY_TEXT, APPOINTMENT_CLASSES, APPOINTMENT_TYPE_CLASSES, RECURRING_LABEL, @@ -17,16 +18,28 @@ export interface GridAppointmentViewProperties extends BaseAppointmentViewProper modifiers: { empty: boolean; }; + allowResize?: boolean; + resizableConfig?: ResizableProperties; } export class GridAppointmentView extends BaseAppointmentView { override _initMarkup(): void { super._initMarkup(); + this.renderResizable(); + // eslint-disable-next-line no-void void this.applyElementColor(); } + private renderResizable(): void { + if (!this.option().allowResize) { + return; + } + + this._createComponent(this.$element(), Resizable, this.option().resizableConfig ?? {}); + } + public override resize( geometry?: { height: number; width: number; top: number; left: number }, ): void { 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 ac8aa5a97391..d5cb58f6a435 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -56,6 +56,7 @@ const getProperties = (options: { showEditAppointmentPopup: (): void => {}, allowDelete: false, onDeleteKeyPress: (): void => {}, + getResizableConfig: () => undefined, }); const createAppointments = ( diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index 4a8a1f1c233e..c5fccac87b61 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -16,6 +16,7 @@ import { isElementInDom } from '@ts/core/utils/m_dom'; import type { DOMComponentProperties } from '@ts/core/widget/dom_component'; import DOMComponent from '@ts/core/widget/dom_component'; import type { OptionChanged } from '@ts/core/widget/types'; +import type { ResizableProperties } from '@ts/ui/resizable/resizable'; import type { AppointmentTooltipExtraOptions } from '../tooltip_strategies/tooltip_strategy_base'; import type { @@ -85,6 +86,10 @@ export interface AppointmentsProperties extends DOMComponentProperties void; + + getResizableConfig: ( + viewModel: AppointmentItemViewModel, + ) => ResizableProperties | undefined; } export class Appointments extends DOMComponent { @@ -192,6 +197,7 @@ export class Appointments extends DOMComponent undefined, }; } @@ -391,6 +397,8 @@ export class Appointments extends DOMComponent Date: Wed, 1 Jul 2026 12:35:13 +0400 Subject: [PATCH 05/16] feat(scheduler): resize timed appointments in the new collection --- .../__tests__/appointments_resizing.test.ts | 80 +++++++++ .../js/__internal/scheduler/scheduler.ts | 168 ++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 packages/devextreme/js/__internal/scheduler/__tests__/appointments_resizing.test.ts diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_resizing.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_resizing.test.ts new file mode 100644 index 000000000000..c6fb140ddb99 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_resizing.test.ts @@ -0,0 +1,80 @@ +import { + afterEach, beforeEach, describe, expect, it, +} from '@jest/globals'; +import fx from '@js/common/core/animation/fx'; +import $ from '@js/core/renderer'; +import type { Properties } from '@js/ui/scheduler'; + +import { createScheduler as baseCreateScheduler } from './__mock__/create_scheduler'; +import { setupSchedulerTestEnvironment } from './__mock__/mock_scheduler'; + +const createScheduler = (config: Properties): +ReturnType => baseCreateScheduler({ + ...config, + // eslint-disable-next-line @typescript-eslint/naming-convention + _newAppointments: true, +}); + +const RESIZE_HANDLE_SELECTOR = '.dx-resizable-handle'; + +const baseConfig: Properties = { + views: ['week'], + currentView: 'week', + currentDate: new Date(2024, 0, 1), + height: 600, +}; + +const timedAppointment = { + text: 'Timed', + startDate: new Date(2024, 0, 1, 9, 0), + endDate: new Date(2024, 0, 1, 10, 0), +}; + +const countResizeHandles = (element: Element | null | undefined): number => ( + element?.querySelectorAll(RESIZE_HANDLE_SELECTOR).length ?? 0 +); + +describe('Appointments Resizing', () => { + beforeEach(() => { + setupSchedulerTestEnvironment(); + fx.off = true; + }); + + afterEach(() => { + const $scheduler = $('.dx-scheduler'); + // @ts-expect-error + $scheduler.dxScheduler('dispose'); + document.body.innerHTML = ''; + fx.off = false; + }); + + it('should render resize handles on a grid appointment when resizing is allowed', async () => { + const { POM } = await createScheduler({ + ...baseConfig, + dataSource: [timedAppointment], + editing: { allowUpdating: true, allowResizing: true }, + }); + + expect(countResizeHandles(POM.getAppointment('Timed').element)).toBeGreaterThan(0); + }); + + it('should not render resize handles when resizing is disabled', async () => { + const { POM } = await createScheduler({ + ...baseConfig, + dataSource: [timedAppointment], + editing: { allowUpdating: true, allowResizing: false }, + }); + + expect(countResizeHandles(POM.getAppointment('Timed').element)).toBe(0); + }); + + it('should not render resize handles on all-day appointments', async () => { + const { POM } = await createScheduler({ + ...baseConfig, + dataSource: [{ ...timedAppointment, text: 'AllDay', allDay: true }], + editing: { allowUpdating: true, allowResizing: true }, + }); + + expect(countResizeHandles(POM.getAppointment('AllDay').element)).toBe(0); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/scheduler.ts b/packages/devextreme/js/__internal/scheduler/scheduler.ts index b89dd02c0729..e56ab4c04838 100644 --- a/packages/devextreme/js/__internal/scheduler/scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/scheduler.ts @@ -59,6 +59,7 @@ import errors from '@js/ui/widget/ui.errors'; import type { Options } from '@ts/core/options/m_index'; import { dateUtilsTs } from '@ts/core/utils/date'; import type { OptionChanged } from '@ts/core/widget/types'; +import type { ResizableProperties } from '@ts/ui/resizable/resizable'; import type Scrollable from '@ts/ui/scroll_view/scrollable'; import { createA11yStatusContainer } from './a11y_status/a11y_status_render'; @@ -70,7 +71,11 @@ import { AppointmentPopup } from './appointment_popup/popup'; import AppointmentCollection, { type AppointmentCollectionOptions } from './appointments/m_appointment_collection'; import type { AppointmentsProperties } from './appointments_new/appointments'; import { Appointments } from './appointments_new/appointments'; +import { getDeltaTime } from './appointments_new/resizing/get_delta_time'; +import { getResizableConfig } from './appointments_new/resizing/get_resizable_config'; +import { getResizedDates, isStartDateResized } from './appointments_new/resizing/get_resized_dates'; import NotifyScheduler from './base/widget_notify_scheduler'; +import { VERTICAL_VIEW_TYPES } from './constants'; import { SchedulerHeader } from './header/header'; import type { HeaderOptions } from './header/types'; import { hide as hideLoading, show as showLoading } from './loading'; @@ -355,6 +360,18 @@ type AppointmentsEditingOptions = Partial<{ allowAllDayResize: boolean; }>; +interface AppointmentResizeEvent { + element: Element; + handles: { + top: boolean; + bottom: boolean; + left: boolean; + right: boolean; + }; + width: number; + height: number; +} + class Scheduler extends SchedulerOptionsBaseWidget { // NOTE: Do not initialize variables here, because `_initMarkup` function runs before constructor, // and initialization in constructor will erase the data @@ -380,6 +397,8 @@ class Scheduler extends SchedulerOptionsBaseWidget { private appointmentDragController!: AppointmentDragController; + private appointmentResizeInitialSize: { width: number; height: number } | null = null; + appointmentDataSource!: AppointmentDataSource; declare _dataSource: DataSource | undefined; @@ -1356,6 +1375,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { onDeleteKeyPress: (e) => { this.checkAndDeleteAppointment(e.appointmentData, e.targetedAppointmentData); }, + getResizableConfig: (viewModel) => this.createAppointmentResizableConfig(viewModel), getResourceManager: () => this.resourceManager, getAppointmentDataSource: () => this.appointmentDataSource, @@ -2011,6 +2031,154 @@ class Scheduler extends SchedulerOptionsBaseWidget { }); } + private createAppointmentResizableConfig( + viewModel: AppointmentItemViewModel, + ): ResizableProperties | undefined { + if (!this.allowResizing() || viewModel.allDay || viewModel.skipResizing) { + return undefined; + } + + const rule = getResizableConfig({ + direction: viewModel.direction === 'horizontal' ? 'horizontal' : 'vertical', + cellWidth: this._workSpace.getCellWidth(), + cellHeight: this._workSpace.getCellHeight(), + resizableStep: this._workSpace.positionHelper.getResizableStep(), + reduced: viewModel.reduced, + isGroupedByDate: this._workSpace.isGroupedByDate(), + rtlEnabled: Boolean(this.option('rtlEnabled')), + }); + + return { + ...rule, + onCancelByEsc: true, + onResizeStart: (e): void => this.onAppointmentResizeStart( + e as unknown as AppointmentResizeEvent, + ), + onResizeEnd: (e): void => { + this.onAppointmentResizeEnd(e as unknown as AppointmentResizeEvent) + .catch((error) => { throw error; }); + }, + }; + } + + private onAppointmentResizeStart(e: AppointmentResizeEvent): void { + this.appointmentResizeInitialSize = { width: e.width, height: e.height }; + } + + private getResizeDeltaTime(e: AppointmentResizeEvent): number { + const initialSize = this.appointmentResizeInitialSize ?? { width: e.width, height: e.height }; + + return getDeltaTime( + { width: e.width, height: e.height }, + initialSize, + { + viewType: this.currentView.type, + cellSize: { + width: this._workSpace.getCellWidth(), + height: this._workSpace.getCellHeight(), + }, + cellDurationInMinutes: this._workSpace.option('cellDuration') as number, + resizableStep: this._workSpace.positionHelper.getResizableStep(), + isAllDayPanel: false, + }, + ); + } + + private getEndResizeStartDate( + e: AppointmentResizeEvent, + rawAppointment: SafeAppointment, + appointmentStartDate: Date, + ): Date { + const adapter = new AppointmentAdapter(rawAppointment, this._dataAccessors); + const { startDateTimeZone, isRecurrent } = adapter; + + if (!e.handles.top && !isRecurrent) { + return this.timeZoneCalculator.createDate(adapter.startDate, 'toGrid', startDateTimeZone); + } + + return appointmentStartDate; + } + + private onAppointmentResizeEnd(e: AppointmentResizeEvent): Promise { + const $element = $(e.element); + const settings = this._appointments + .getAppointmentSettings($element) as AppointmentItemViewModel; + const appointmentData = settings.itemData; + const { info } = settings; + const viewOffset = this.getViewOffsetMs(); + + const startDate = this.getEndResizeStartDate(e, appointmentData, info.appointment.startDate); + const { endDate } = info.appointment; + + const dateRange = getResizedDates({ + startDate: dateUtilsTs.addOffsets(startDate, -viewOffset), + endDate: dateUtilsTs.addOffsets(endDate, -viewOffset), + deltaTime: this.getResizeDeltaTime(e), + isStartDateChanged: isStartDateResized({ + handles: e.handles, + isVerticalDirection: VERTICAL_VIEW_TYPES.includes(this.currentView.type), + isAllDay: false, + rtlEnabled: Boolean(this.option('rtlEnabled')), + }), + needCorrectDates: !['month', 'timelineMonth'].includes(this.currentView.type), + startDayHour: this.option('startDayHour'), + endDayHour: this.option('endDayHour'), + }); + + return this.updateResizedAppointment( + appointmentData, + { + startDate: dateUtilsTs.addOffsets(dateRange.startDate, viewOffset), + endDate: dateUtilsTs.addOffsets(dateRange.endDate, viewOffset), + }, + info.sourceAppointment.startDate, + ); + } + + private updateResizedAppointment( + sourceAppointment: SafeAppointment, + dateRange: { startDate: Date; endDate: Date }, + exceptionStartDate: Date, + ): Promise { + const tz = this.timeZoneCalculator; + const gridAdapter = new AppointmentAdapter(sourceAppointment, this._dataAccessors).clone(); + + gridAdapter.startDate = new Date(dateRange.startDate); + gridAdapter.endDate = new Date(dateRange.endDate); + + const convertedBackAdapter = gridAdapter + .clone() + .calculateDates(tz, 'fromGrid') + .calculateDates(tz, 'toGrid'); + + const startDateDelta = gridAdapter.startDate.getTime() + - convertedBackAdapter.startDate.getTime(); + const endDateDelta = gridAdapter.endDate.getTime() + - convertedBackAdapter.endDate.getTime(); + + gridAdapter.startDate = dateUtilsTs.addOffsets(gridAdapter.startDate, startDateDelta); + gridAdapter.endDate = dateUtilsTs.addOffsets(gridAdapter.endDate, endDateDelta); + + const data = gridAdapter.calculateDates(tz, 'fromGrid').source; + + return new Promise((resolve) => { + this.checkRecurringAppointment( + sourceAppointment, + data, + exceptionStartDate, + () => { + this.updateAppointmentCore(sourceAppointment, data) + .then(() => resolve(), () => resolve()); + }, + false, + undefined, + undefined, + undefined, + (): void => { resolve(); }, + ); + }); + } + checkRecurringAppointment( rawAppointment: SafeAppointment, singleAppointment: SafeAppointment, From 11b61796013322eebf5988924918a05217548200 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:11:42 +0400 Subject: [PATCH 06/16] feat(scheduler): focus resized appointment on resize start --- .../__tests__/appointments_resizing.test.ts | 16 ++++++++++++++++ .../appointments.focus_controller.ts | 2 +- .../scheduler/appointments_new/appointments.ts | 8 ++++++++ .../js/__internal/scheduler/scheduler.ts | 1 + 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_resizing.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_resizing.test.ts index c6fb140ddb99..62bcea3cdb28 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_resizing.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_resizing.test.ts @@ -4,6 +4,7 @@ import { 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__/mock_scheduler'; @@ -77,4 +78,19 @@ describe('Appointments Resizing', () => { expect(countResizeHandles(POM.getAppointment('AllDay').element)).toBe(0); }); + + it('should focus the appointment on resize start', async () => { + const { POM } = await createScheduler({ + ...baseConfig, + dataSource: [timedAppointment], + editing: { allowUpdating: true, allowResizing: true }, + }); + + const appointment = POM.getAppointment('Timed').element as HTMLElement; + const handle = appointment.querySelector(RESIZE_HANDLE_SELECTOR) as HTMLElement; + + eventsEngine.trigger(handle, { type: 'dxdragstart', target: handle }); + + expect(document.activeElement).toBe(appointment); + }); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts index 4a974c474540..35401b6019ba 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts @@ -176,7 +176,7 @@ export class AppointmentsFocusController { } } - private focusViewItem(viewItem: ViewItem): void { + public focusViewItem(viewItem: ViewItem): void { this.resetTabIndex(viewItem.option().sortedIndex); focus.trigger(viewItem?.$element()); } diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index c5fccac87b61..e7898cc10fec 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -127,6 +127,14 @@ export class Appointments extends DOMComponent Date: Wed, 1 Jul 2026 14:40:06 +0400 Subject: [PATCH 07/16] refactor(scheduler): remove unused all-day resize helper --- .../get_appointment_date_range.test.ts | 144 -------------- .../resizing/get_appointment_date_range.ts | 177 ------------------ .../resizing/get_resized_dates.ts | 6 +- .../appointments_new/resizing/types.ts | 48 ----- 4 files changed, 5 insertions(+), 370 deletions(-) delete mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.test.ts delete mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.ts delete mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/types.ts diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.test.ts deleted file mode 100644 index d574fb172846..000000000000 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { - describe, expect, it, -} from '@jest/globals'; - -import type { CellRect, DOMMetaData, ViewCellData } from '../../types'; -import type { AppointmentItemViewModel } from '../../view_model/types'; -import { getAppointmentDateRange } from './get_appointment_date_range'; -import type { GetAppointmentDateRangeOptions, Rect } from './types'; - -const CELL_WIDTH = 100; -const CELL_HEIGHT = 50; -const COLUMN_COUNT = 7; - -const mockViewDataProvider = { - getCellData: (rowIndex: number, columnIndex: number): ViewCellData => ({ - startDate: new Date(2024, 0, 1 + columnIndex), - endDate: new Date(2024, 0, 2 + columnIndex), - index: columnIndex, - } as ViewCellData), -}; - -const mockDOMMetaData = (): DOMMetaData => ({ - allDayPanelCellsMeta: Array.from({ length: COLUMN_COUNT }, (_, columnIndex): CellRect => ({ - top: 0, - left: columnIndex * CELL_WIDTH, - width: CELL_WIDTH, - height: CELL_HEIGHT, - })), - dateTableCellsMeta: [], -}); - -const createOptions = (overrides: { - handles: { left: boolean; right: boolean }; - appointmentRect: Rect; - sourceStartDate: Date; - sourceEndDate: Date; - rtlEnabled?: boolean; -}): GetAppointmentDateRangeOptions => ({ - handles: overrides.handles, - appointmentSettings: { - allDay: true, - rowIndex: 0, - columnIndex: 0, - info: { - sourceAppointment: { - startDate: overrides.sourceStartDate, - endDate: overrides.sourceEndDate, - }, - appointment: { allDay: true }, - }, - } as unknown as AppointmentItemViewModel, - isVerticalGroupedWorkSpace: false, - appointmentRect: overrides.appointmentRect, - parentAppointmentRect: { - top: 0, left: 0, width: COLUMN_COUNT * CELL_WIDTH, height: CELL_HEIGHT, - }, - viewDataProvider: mockViewDataProvider as unknown as GetAppointmentDateRangeOptions['viewDataProvider'], - rtlEnabled: overrides.rtlEnabled ?? false, - DOMMetaData: mockDOMMetaData(), -} as unknown as GetAppointmentDateRangeOptions); - -describe('getAppointmentDateRange', () => { - it('should set endDate from the last occupied cell when the right handle is dragged', () => { - const options = createOptions({ - handles: { left: false, right: true }, - appointmentRect: { - top: 0, left: CELL_WIDTH, width: CELL_WIDTH, height: CELL_HEIGHT, - }, - sourceStartDate: new Date(2024, 0, 2), - sourceEndDate: new Date(2024, 0, 4), - }); - - const range = getAppointmentDateRange(options); - - expect(range.startDate).toEqual(new Date(2024, 0, 2)); - expect(range.endDate).toEqual(new Date(2024, 0, 3)); - }); - - it('should set startDate from the first occupied cell when the left handle is dragged', () => { - const options = createOptions({ - handles: { left: true, right: false }, - appointmentRect: { - top: 0, left: 2 * CELL_WIDTH, width: CELL_WIDTH, height: CELL_HEIGHT, - }, - sourceStartDate: new Date(2024, 0, 2), - sourceEndDate: new Date(2024, 0, 4), - }); - - const range = getAppointmentDateRange(options); - - expect(range.startDate).toEqual(new Date(2024, 0, 3)); - expect(range.endDate).toEqual(new Date(2024, 0, 4)); - }); - - it('should set endDate from the last occupied cell when the right handle is dragged across several cells', () => { - const options = createOptions({ - handles: { left: false, right: true }, - appointmentRect: { - top: 0, left: CELL_WIDTH, width: 2 * CELL_WIDTH, height: CELL_HEIGHT, - }, - sourceStartDate: new Date(2024, 0, 2), - sourceEndDate: new Date(2024, 0, 4), - }); - - const range = getAppointmentDateRange(options); - - expect(range.startDate).toEqual(new Date(2024, 0, 2)); - expect(range.endDate).toEqual(new Date(2024, 0, 4)); - }); - - it('should set startDate from the first occupied cell when the right handle is dragged in RTL', () => { - const options = createOptions({ - handles: { left: false, right: true }, - appointmentRect: { - top: 0, left: CELL_WIDTH, width: CELL_WIDTH, height: CELL_HEIGHT, - }, - sourceStartDate: new Date(2024, 0, 2), - sourceEndDate: new Date(2024, 0, 4), - rtlEnabled: true, - }); - - const range = getAppointmentDateRange(options); - - expect(range.startDate).toEqual(new Date(2024, 0, 2)); - expect(range.endDate).toEqual(new Date(2024, 0, 4)); - }); - - it('should set endDate from the last occupied cell when the left handle is dragged in RTL', () => { - const options = createOptions({ - handles: { left: true, right: false }, - appointmentRect: { - top: 0, left: CELL_WIDTH, width: CELL_WIDTH, height: CELL_HEIGHT, - }, - sourceStartDate: new Date(2024, 0, 2), - sourceEndDate: new Date(2024, 0, 4), - rtlEnabled: true, - }); - - const range = getAppointmentDateRange(options); - - expect(range.startDate).toEqual(new Date(2024, 0, 2)); - expect(range.endDate).toEqual(new Date(2024, 0, 3)); - }); -}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.ts deleted file mode 100644 index 4a1401ca0afd..000000000000 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { dateUtilsTs } from '@ts/core/utils/date'; -import { dateUtils } from '@ts/core/utils/m_date'; - -import type { ViewCellData } from '../../types'; -import type { - CellsInfo, - DateRange, - GetAppointmentDateRangeOptions, - GetAppointmentDateRangeOptionsExtended, - Rect, -} from './types'; - -const toMs = dateUtils.dateToMilliseconds; - -const getCellData = ( - { viewDataProvider }: GetAppointmentDateRangeOptionsExtended, - cellRowIndex: number, - cellColumnIndex: number, - isOccupiedAllDay: boolean, - isAllDay = false, - rtlEnabled = false, -): ViewCellData => { - const cellData = viewDataProvider.getCellData( - cellRowIndex, - cellColumnIndex, - isOccupiedAllDay, - rtlEnabled, - ); - - if (!isAllDay) { - cellData.endDate = dateUtilsTs.addOffsets(cellData.startDate, toMs('day')); - } - - return cellData; -}; - -const getAppointmentLeftCell = (options: GetAppointmentDateRangeOptionsExtended): ViewCellData => { - const { - cellHeight, - cellWidth, - relativeAppointmentRect, - appointment, - rtlEnabled, - } = options; - - const cellRowIndex = Math.floor(relativeAppointmentRect.top / cellHeight); - const cellColumnIndex = Math.round(relativeAppointmentRect.left / cellWidth); - - return getCellData( - options, - cellRowIndex, - cellColumnIndex, - appointment.isOccupiedAllDay, - appointment.isAllDay, - rtlEnabled, - ); -}; - -const getResizedDateRange = (options: GetAppointmentDateRangeOptionsExtended): DateRange => { - const { - rtlEnabled, - handles, - appointment, - relativeAppointmentRect, - cellWidth, - cellCountInRow, - } = options; - - const leftCell = getAppointmentLeftCell(options); - const cellsAmount = Math.round(relativeAppointmentRect.width / cellWidth); - const isStartEdgeResized = rtlEnabled ? handles.right : handles.left; - - if (isStartEdgeResized) { - const firstCell = rtlEnabled - ? getCellData( - options, - Math.floor(leftCell.index / cellCountInRow), - leftCell.index - cellsAmount + 1, - appointment.isOccupiedAllDay, - appointment.isAllDay, - ) - : leftCell; - - return { - startDate: firstCell.startDate, - endDate: firstCell.startDate > appointment.endDate - ? firstCell.startDate - : appointment.endDate, - }; - } - - const lastCellIndex = leftCell.index + cellsAmount - 1; - const lastCell = rtlEnabled - ? leftCell - : getCellData( - options, - Math.floor(lastCellIndex / cellCountInRow), - lastCellIndex % cellCountInRow, - appointment.isOccupiedAllDay, - appointment.isAllDay, - ); - - return { - startDate: lastCell.endDate < appointment.startDate - ? lastCell.endDate - : appointment.startDate, - endDate: lastCell.endDate, - }; -}; - -const getRelativeAppointmentRect = (appointmentRect: Rect, parentAppointmentRect: Rect): Rect => { - const left = appointmentRect.left - parentAppointmentRect.left; - const top = appointmentRect.top - parentAppointmentRect.top; - const width = left < 0 - ? appointmentRect.width + left - : appointmentRect.width; - const height = top < 0 - ? appointmentRect.height + top - : appointmentRect.height; - - return { - left: Math.max(0, left), - top: Math.max(0, top), - width, - height, - }; -}; - -const getAppointmentCellsInfo = (options: GetAppointmentDateRangeOptions): CellsInfo => { - const { - appointmentSettings, - isVerticalGroupedWorkSpace, - DOMMetaData, - } = options; - - const DOMMetaTable = appointmentSettings.allDay && !isVerticalGroupedWorkSpace - ? [DOMMetaData.allDayPanelCellsMeta] - : DOMMetaData.dateTableCellsMeta; - - const { - height: cellHeight, - width: cellWidth, - } = DOMMetaTable[appointmentSettings.rowIndex][appointmentSettings.columnIndex]; - const cellCountInRow = DOMMetaTable[appointmentSettings.rowIndex].length; - - return { - cellWidth, - cellHeight, - cellCountInRow, - }; -}; - -export const getAppointmentDateRange = (options: GetAppointmentDateRangeOptions): DateRange => { - const { - appointmentSettings, - } = options; - - const relativeAppointmentRect = getRelativeAppointmentRect( - options.appointmentRect, - options.parentAppointmentRect, - ); - const cellInfo = getAppointmentCellsInfo(options); - const appointment = { - startDate: appointmentSettings.info.sourceAppointment.startDate, - endDate: appointmentSettings.info.sourceAppointment.endDate, - isAllDay: Boolean(appointmentSettings.info.appointment.allDay), - isOccupiedAllDay: Boolean(appointmentSettings.allDay), - }; - const extendedOptions = { - ...options, - ...cellInfo, - appointment, - relativeAppointmentRect, - }; - - return getResizedDateRange(extendedOptions); -}; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.ts index 347c7dc72b99..fd012dd235fb 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.ts @@ -1,10 +1,14 @@ import dateUtils from '@js/core/utils/date'; import timeZoneUtils from '../../utils_time_zone'; -import type { DateRange } from './types'; const toMs = dateUtils.dateToMilliseconds; +interface DateRange { + startDate: Date; + endDate: Date; +} + interface ResizeHandles { top: boolean; left: boolean; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/types.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/types.ts deleted file mode 100644 index 350526fa2ce7..000000000000 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { TimeZoneCalculator } from '../../r1/timezone_calculator'; -import type { DOMMetaData, ViewDataProviderType } from '../../types'; -import type { AppointmentDataAccessor } from '../../utils/data_accessor/appointment_data_accessor'; -import type { AppointmentItemViewModel } from '../../view_model/types'; - -export type Rect = Pick; - -export interface GetAppointmentDateRangeOptions { - handles: { - left: boolean; - right: boolean; - }; - appointmentSettings: AppointmentItemViewModel; - isVerticalGroupedWorkSpace: boolean; - appointmentRect: Rect; - parentAppointmentRect: Rect; - viewDataProvider: ViewDataProviderType; - isDateAndTimeView: boolean; - startDayHour: number; - endDayHour: number; - timeZoneCalculator: TimeZoneCalculator; - dataAccessors: AppointmentDataAccessor; - rtlEnabled?: boolean; - DOMMetaData: DOMMetaData; - viewOffset: number; -} - -export interface CellsInfo { - cellWidth: number; - cellHeight: number; - cellCountInRow: number; -} - -export type GetAppointmentDateRangeOptionsExtended = Omit - & CellsInfo & { - relativeAppointmentRect: Rect; - appointment: { - startDate: Date; - endDate: Date; - isAllDay: boolean; - isOccupiedAllDay: boolean; - }; - }; - -export interface DateRange { - startDate: Date; - endDate: Date; -} From 8fc3a6a1012510d91d462d93e08e0d4de3b8be7c Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:45:56 +0400 Subject: [PATCH 08/16] fix(scheduler): address resize PR review comments --- .../js/__internal/scheduler/scheduler.ts | 38 ++++++++----------- .../js/__internal/ui/resizable/resizable.ts | 2 + 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/scheduler.ts b/packages/devextreme/js/__internal/scheduler/scheduler.ts index 9c62e199b2fe..7d370c39883c 100644 --- a/packages/devextreme/js/__internal/scheduler/scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/scheduler.ts @@ -2054,10 +2054,9 @@ class Scheduler extends SchedulerOptionsBaseWidget { onResizeStart: (e): void => this.onAppointmentResizeStart( e as unknown as AppointmentResizeEvent, ), - onResizeEnd: (e): void => { - this.onAppointmentResizeEnd(e as unknown as AppointmentResizeEvent) - .catch((error) => { throw error; }); - }, + onResizeEnd: (e): void => this.onAppointmentResizeEnd( + e as unknown as AppointmentResizeEvent, + ), }; } @@ -2100,7 +2099,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { return appointmentStartDate; } - private onAppointmentResizeEnd(e: AppointmentResizeEvent): Promise { + private onAppointmentResizeEnd(e: AppointmentResizeEvent): void { const $element = $(e.element); const settings = this._appointments .getAppointmentSettings($element) as AppointmentItemViewModel; @@ -2126,7 +2125,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { endDayHour: this.option('endDayHour'), }); - return this.updateResizedAppointment( + this.updateResizedAppointment( appointmentData, { startDate: dateUtilsTs.addOffsets(dateRange.startDate, viewOffset), @@ -2140,7 +2139,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { sourceAppointment: SafeAppointment, dateRange: { startDate: Date; endDate: Date }, exceptionStartDate: Date, - ): Promise { + ): void { const tz = this.timeZoneCalculator; const gridAdapter = new AppointmentAdapter(sourceAppointment, this._dataAccessors).clone(); @@ -2162,22 +2161,15 @@ class Scheduler extends SchedulerOptionsBaseWidget { const data = gridAdapter.calculateDates(tz, 'fromGrid').source; - return new Promise((resolve) => { - this.checkRecurringAppointment( - sourceAppointment, - data, - exceptionStartDate, - () => { - this.updateAppointmentCore(sourceAppointment, data) - .then(() => resolve(), () => resolve()); - }, - false, - undefined, - undefined, - undefined, - (): void => { resolve(); }, - ); - }); + this.checkRecurringAppointment( + sourceAppointment, + data, + exceptionStartDate, + () => { + this.updateAppointmentCore(sourceAppointment, data).catch(noop); + }, + false, + ); } checkRecurringAppointment( diff --git a/packages/devextreme/js/__internal/ui/resizable/resizable.ts b/packages/devextreme/js/__internal/ui/resizable/resizable.ts index 3c4dc97a06bc..bc80c823c86e 100644 --- a/packages/devextreme/js/__internal/ui/resizable/resizable.ts +++ b/packages/devextreme/js/__internal/ui/resizable/resizable.ts @@ -740,6 +740,8 @@ class Resizable extends DOMComponent { }); move(this.$element(), this._elementLocation); + + triggerResizeEvent(this.$element()); } _renderWidth(width: number): void { From b53bf97198abad33ca805c24f47100916cecb3b3 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:55:54 +0400 Subject: [PATCH 09/16] fix(resizable): use internal events engine in test to satisfy check-types --- .../devextreme/js/__internal/ui/resizable/resizable.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts b/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts index eb5a658d4ef8..15dae809ceb6 100644 --- a/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts +++ b/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, jest, } from '@jest/globals'; -import eventsEngine from '@js/common/core/events/core/events_engine'; import $ from '@js/core/renderer'; +import eventsEngine from '@ts/events/core/m_events_engine'; import Resizable from './resizable'; From 9a97fe991da61f345ff7e879d820bdd94d624ab4 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:17:47 +0400 Subject: [PATCH 10/16] test(scheduler): dispose resizable widgets in test and fix delta test title --- .../appointments_new/resizing/get_delta_time.test.ts | 2 +- .../devextreme/js/__internal/ui/resizable/resizable.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_delta_time.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_delta_time.test.ts index e07a51c9abf0..dde9bb19f337 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_delta_time.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_delta_time.test.ts @@ -70,7 +70,7 @@ describe('getDeltaTime', () => { }); ['timelineDay', 'timelineWeek', 'timelineWorkWeek'].forEach((view) => { - it(`should return zero for not resized appointment in horizontal ${view} view`, () => { + it(`should return correct delta in px for resized appointment in horizontal ${view} view`, () => { expect(getDeltaTime( { width: 50, height: 100 }, { width: 100, height: 100 }, diff --git a/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts b/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts index 15dae809ceb6..0e94054ab2e0 100644 --- a/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts +++ b/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts @@ -8,6 +8,8 @@ import Resizable from './resizable'; const HANDLE_BOTTOM_CLASS = 'dx-resizable-handle-bottom'; +const instances: Resizable[] = []; + const createResizable = (options: object = {}): { instance: Resizable; $element: ReturnType; @@ -18,6 +20,8 @@ const createResizable = (options: object = {}): { const instance = new Resizable($element, options); const $handle = $element.find(`.${HANDLE_BOTTOM_CLASS}`); + instances.push(instance); + return { instance, $element, $handle }; }; @@ -43,6 +47,8 @@ describe('Resizable onCancelByEsc', () => { }); afterEach(() => { + instances.forEach((instance) => instance.dispose()); + instances.length = 0; document.body.innerHTML = ''; }); From 8c97a408f9ab77f7760608cfc6c9c0ef70823474 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:15:27 +0400 Subject: [PATCH 11/16] fix(resizable): keep resize on dragend without dragstart (esc-cancel regression) --- .../devextreme/js/__internal/ui/resizable/resizable.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/resizable/resizable.ts b/packages/devextreme/js/__internal/ui/resizable/resizable.ts index bc80c823c86e..f81f006ecaa3 100644 --- a/packages/devextreme/js/__internal/ui/resizable/resizable.ts +++ b/packages/devextreme/js/__internal/ui/resizable/resizable.ts @@ -119,6 +119,8 @@ class Resizable extends DOMComponent { _resizeCancelAction?: (e: Record) => void; + _resizeCanceled = false; + _getDefaultOptions(): ResizableProperties { return { ...super._getDefaultOptions(), @@ -257,6 +259,7 @@ class Resizable extends DOMComponent { return; } + this._resizeCanceled = false; this._toggleResizingClass(true); this._movingSides = this._getMovingSides(e); @@ -458,7 +461,7 @@ class Resizable extends DOMComponent { } _dragHandler(e: DragEvent): void { - if (!this._isResizing()) { + if (this._resizeCanceled) { return; } @@ -698,7 +701,8 @@ class Resizable extends DOMComponent { } _dragEndHandler(e: DxEvent): void { - if (!this._isResizing()) { + if (this._resizeCanceled) { + this._resizeCanceled = false; return; } @@ -721,6 +725,7 @@ class Resizable extends DOMComponent { } _cancelResize(e: DxEvent): void { + this._resizeCanceled = true; this._restoreSize(); this._toggleResizingClass(false); From f247dbb79b4410dba3e9c859a0878e14922036a3 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:45:35 +0400 Subject: [PATCH 12/16] fix(scheduler): bound appointment resize to scrollable area and reset cached size --- packages/devextreme/js/__internal/scheduler/scheduler.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/devextreme/js/__internal/scheduler/scheduler.ts b/packages/devextreme/js/__internal/scheduler/scheduler.ts index 7d370c39883c..c6123eea7eb7 100644 --- a/packages/devextreme/js/__internal/scheduler/scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/scheduler.ts @@ -2050,6 +2050,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { return { ...rule, + area: this._workSpace.getScrollableContainer() as unknown as ResizableProperties['area'], onCancelByEsc: true, onResizeStart: (e): void => this.onAppointmentResizeStart( e as unknown as AppointmentResizeEvent, @@ -2133,6 +2134,8 @@ class Scheduler extends SchedulerOptionsBaseWidget { }, info.sourceAppointment.startDate, ); + + this.appointmentResizeInitialSize = null; } private updateResizedAppointment( From e3dcc0bab9a00872a52f705a9f5d303bec87943f Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:08:18 +0400 Subject: [PATCH 13/16] feat(scheduler): support all-day appointment resize in the new collection --- .../__tests__/appointments_resizing.test.ts | 16 +- .../get_appointment_date_range.test.ts | 144 ++++++++++++++ .../resizing/get_appointment_date_range.ts | 177 ++++++++++++++++++ .../resizing/get_resized_dates.ts | 6 +- .../appointments_new/resizing/types.ts | 48 +++++ .../js/__internal/scheduler/scheduler.ts | 69 +++++-- 6 files changed, 440 insertions(+), 20 deletions(-) create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.test.ts create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.ts create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/resizing/types.ts diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_resizing.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_resizing.test.ts index 62bcea3cdb28..de84effbf9c6 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_resizing.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_resizing.test.ts @@ -69,13 +69,27 @@ describe('Appointments Resizing', () => { expect(countResizeHandles(POM.getAppointment('Timed').element)).toBe(0); }); - it('should not render resize handles on all-day appointments', async () => { + it('should render horizontal resize handles on all-day appointments', async () => { const { POM } = await createScheduler({ ...baseConfig, dataSource: [{ ...timedAppointment, text: 'AllDay', allDay: true }], editing: { allowUpdating: true, allowResizing: true }, }); + const { element } = POM.getAppointment('AllDay'); + + expect(countResizeHandles(element)).toBeGreaterThan(0); + expect(element?.querySelectorAll('.dx-resizable-handle-left, .dx-resizable-handle-right').length) + .toBeGreaterThan(0); + }); + + it('should not render resize handles on all-day appointments when resizing is disabled', async () => { + const { POM } = await createScheduler({ + ...baseConfig, + dataSource: [{ ...timedAppointment, text: 'AllDay', allDay: true }], + editing: { allowUpdating: true, allowResizing: false }, + }); + expect(countResizeHandles(POM.getAppointment('AllDay').element)).toBe(0); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.test.ts new file mode 100644 index 000000000000..d574fb172846 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.test.ts @@ -0,0 +1,144 @@ +import { + describe, expect, it, +} from '@jest/globals'; + +import type { CellRect, DOMMetaData, ViewCellData } from '../../types'; +import type { AppointmentItemViewModel } from '../../view_model/types'; +import { getAppointmentDateRange } from './get_appointment_date_range'; +import type { GetAppointmentDateRangeOptions, Rect } from './types'; + +const CELL_WIDTH = 100; +const CELL_HEIGHT = 50; +const COLUMN_COUNT = 7; + +const mockViewDataProvider = { + getCellData: (rowIndex: number, columnIndex: number): ViewCellData => ({ + startDate: new Date(2024, 0, 1 + columnIndex), + endDate: new Date(2024, 0, 2 + columnIndex), + index: columnIndex, + } as ViewCellData), +}; + +const mockDOMMetaData = (): DOMMetaData => ({ + allDayPanelCellsMeta: Array.from({ length: COLUMN_COUNT }, (_, columnIndex): CellRect => ({ + top: 0, + left: columnIndex * CELL_WIDTH, + width: CELL_WIDTH, + height: CELL_HEIGHT, + })), + dateTableCellsMeta: [], +}); + +const createOptions = (overrides: { + handles: { left: boolean; right: boolean }; + appointmentRect: Rect; + sourceStartDate: Date; + sourceEndDate: Date; + rtlEnabled?: boolean; +}): GetAppointmentDateRangeOptions => ({ + handles: overrides.handles, + appointmentSettings: { + allDay: true, + rowIndex: 0, + columnIndex: 0, + info: { + sourceAppointment: { + startDate: overrides.sourceStartDate, + endDate: overrides.sourceEndDate, + }, + appointment: { allDay: true }, + }, + } as unknown as AppointmentItemViewModel, + isVerticalGroupedWorkSpace: false, + appointmentRect: overrides.appointmentRect, + parentAppointmentRect: { + top: 0, left: 0, width: COLUMN_COUNT * CELL_WIDTH, height: CELL_HEIGHT, + }, + viewDataProvider: mockViewDataProvider as unknown as GetAppointmentDateRangeOptions['viewDataProvider'], + rtlEnabled: overrides.rtlEnabled ?? false, + DOMMetaData: mockDOMMetaData(), +} as unknown as GetAppointmentDateRangeOptions); + +describe('getAppointmentDateRange', () => { + it('should set endDate from the last occupied cell when the right handle is dragged', () => { + const options = createOptions({ + handles: { left: false, right: true }, + appointmentRect: { + top: 0, left: CELL_WIDTH, width: CELL_WIDTH, height: CELL_HEIGHT, + }, + sourceStartDate: new Date(2024, 0, 2), + sourceEndDate: new Date(2024, 0, 4), + }); + + const range = getAppointmentDateRange(options); + + expect(range.startDate).toEqual(new Date(2024, 0, 2)); + expect(range.endDate).toEqual(new Date(2024, 0, 3)); + }); + + it('should set startDate from the first occupied cell when the left handle is dragged', () => { + const options = createOptions({ + handles: { left: true, right: false }, + appointmentRect: { + top: 0, left: 2 * CELL_WIDTH, width: CELL_WIDTH, height: CELL_HEIGHT, + }, + sourceStartDate: new Date(2024, 0, 2), + sourceEndDate: new Date(2024, 0, 4), + }); + + const range = getAppointmentDateRange(options); + + expect(range.startDate).toEqual(new Date(2024, 0, 3)); + expect(range.endDate).toEqual(new Date(2024, 0, 4)); + }); + + it('should set endDate from the last occupied cell when the right handle is dragged across several cells', () => { + const options = createOptions({ + handles: { left: false, right: true }, + appointmentRect: { + top: 0, left: CELL_WIDTH, width: 2 * CELL_WIDTH, height: CELL_HEIGHT, + }, + sourceStartDate: new Date(2024, 0, 2), + sourceEndDate: new Date(2024, 0, 4), + }); + + const range = getAppointmentDateRange(options); + + expect(range.startDate).toEqual(new Date(2024, 0, 2)); + expect(range.endDate).toEqual(new Date(2024, 0, 4)); + }); + + it('should set startDate from the first occupied cell when the right handle is dragged in RTL', () => { + const options = createOptions({ + handles: { left: false, right: true }, + appointmentRect: { + top: 0, left: CELL_WIDTH, width: CELL_WIDTH, height: CELL_HEIGHT, + }, + sourceStartDate: new Date(2024, 0, 2), + sourceEndDate: new Date(2024, 0, 4), + rtlEnabled: true, + }); + + const range = getAppointmentDateRange(options); + + expect(range.startDate).toEqual(new Date(2024, 0, 2)); + expect(range.endDate).toEqual(new Date(2024, 0, 4)); + }); + + it('should set endDate from the last occupied cell when the left handle is dragged in RTL', () => { + const options = createOptions({ + handles: { left: true, right: false }, + appointmentRect: { + top: 0, left: CELL_WIDTH, width: CELL_WIDTH, height: CELL_HEIGHT, + }, + sourceStartDate: new Date(2024, 0, 2), + sourceEndDate: new Date(2024, 0, 4), + rtlEnabled: true, + }); + + const range = getAppointmentDateRange(options); + + expect(range.startDate).toEqual(new Date(2024, 0, 2)); + expect(range.endDate).toEqual(new Date(2024, 0, 3)); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.ts new file mode 100644 index 000000000000..4a1401ca0afd --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_appointment_date_range.ts @@ -0,0 +1,177 @@ +import { dateUtilsTs } from '@ts/core/utils/date'; +import { dateUtils } from '@ts/core/utils/m_date'; + +import type { ViewCellData } from '../../types'; +import type { + CellsInfo, + DateRange, + GetAppointmentDateRangeOptions, + GetAppointmentDateRangeOptionsExtended, + Rect, +} from './types'; + +const toMs = dateUtils.dateToMilliseconds; + +const getCellData = ( + { viewDataProvider }: GetAppointmentDateRangeOptionsExtended, + cellRowIndex: number, + cellColumnIndex: number, + isOccupiedAllDay: boolean, + isAllDay = false, + rtlEnabled = false, +): ViewCellData => { + const cellData = viewDataProvider.getCellData( + cellRowIndex, + cellColumnIndex, + isOccupiedAllDay, + rtlEnabled, + ); + + if (!isAllDay) { + cellData.endDate = dateUtilsTs.addOffsets(cellData.startDate, toMs('day')); + } + + return cellData; +}; + +const getAppointmentLeftCell = (options: GetAppointmentDateRangeOptionsExtended): ViewCellData => { + const { + cellHeight, + cellWidth, + relativeAppointmentRect, + appointment, + rtlEnabled, + } = options; + + const cellRowIndex = Math.floor(relativeAppointmentRect.top / cellHeight); + const cellColumnIndex = Math.round(relativeAppointmentRect.left / cellWidth); + + return getCellData( + options, + cellRowIndex, + cellColumnIndex, + appointment.isOccupiedAllDay, + appointment.isAllDay, + rtlEnabled, + ); +}; + +const getResizedDateRange = (options: GetAppointmentDateRangeOptionsExtended): DateRange => { + const { + rtlEnabled, + handles, + appointment, + relativeAppointmentRect, + cellWidth, + cellCountInRow, + } = options; + + const leftCell = getAppointmentLeftCell(options); + const cellsAmount = Math.round(relativeAppointmentRect.width / cellWidth); + const isStartEdgeResized = rtlEnabled ? handles.right : handles.left; + + if (isStartEdgeResized) { + const firstCell = rtlEnabled + ? getCellData( + options, + Math.floor(leftCell.index / cellCountInRow), + leftCell.index - cellsAmount + 1, + appointment.isOccupiedAllDay, + appointment.isAllDay, + ) + : leftCell; + + return { + startDate: firstCell.startDate, + endDate: firstCell.startDate > appointment.endDate + ? firstCell.startDate + : appointment.endDate, + }; + } + + const lastCellIndex = leftCell.index + cellsAmount - 1; + const lastCell = rtlEnabled + ? leftCell + : getCellData( + options, + Math.floor(lastCellIndex / cellCountInRow), + lastCellIndex % cellCountInRow, + appointment.isOccupiedAllDay, + appointment.isAllDay, + ); + + return { + startDate: lastCell.endDate < appointment.startDate + ? lastCell.endDate + : appointment.startDate, + endDate: lastCell.endDate, + }; +}; + +const getRelativeAppointmentRect = (appointmentRect: Rect, parentAppointmentRect: Rect): Rect => { + const left = appointmentRect.left - parentAppointmentRect.left; + const top = appointmentRect.top - parentAppointmentRect.top; + const width = left < 0 + ? appointmentRect.width + left + : appointmentRect.width; + const height = top < 0 + ? appointmentRect.height + top + : appointmentRect.height; + + return { + left: Math.max(0, left), + top: Math.max(0, top), + width, + height, + }; +}; + +const getAppointmentCellsInfo = (options: GetAppointmentDateRangeOptions): CellsInfo => { + const { + appointmentSettings, + isVerticalGroupedWorkSpace, + DOMMetaData, + } = options; + + const DOMMetaTable = appointmentSettings.allDay && !isVerticalGroupedWorkSpace + ? [DOMMetaData.allDayPanelCellsMeta] + : DOMMetaData.dateTableCellsMeta; + + const { + height: cellHeight, + width: cellWidth, + } = DOMMetaTable[appointmentSettings.rowIndex][appointmentSettings.columnIndex]; + const cellCountInRow = DOMMetaTable[appointmentSettings.rowIndex].length; + + return { + cellWidth, + cellHeight, + cellCountInRow, + }; +}; + +export const getAppointmentDateRange = (options: GetAppointmentDateRangeOptions): DateRange => { + const { + appointmentSettings, + } = options; + + const relativeAppointmentRect = getRelativeAppointmentRect( + options.appointmentRect, + options.parentAppointmentRect, + ); + const cellInfo = getAppointmentCellsInfo(options); + const appointment = { + startDate: appointmentSettings.info.sourceAppointment.startDate, + endDate: appointmentSettings.info.sourceAppointment.endDate, + isAllDay: Boolean(appointmentSettings.info.appointment.allDay), + isOccupiedAllDay: Boolean(appointmentSettings.allDay), + }; + const extendedOptions = { + ...options, + ...cellInfo, + appointment, + relativeAppointmentRect, + }; + + return getResizedDateRange(extendedOptions); +}; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.ts index fd012dd235fb..347c7dc72b99 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/get_resized_dates.ts @@ -1,14 +1,10 @@ import dateUtils from '@js/core/utils/date'; import timeZoneUtils from '../../utils_time_zone'; +import type { DateRange } from './types'; const toMs = dateUtils.dateToMilliseconds; -interface DateRange { - startDate: Date; - endDate: Date; -} - interface ResizeHandles { top: boolean; left: boolean; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/types.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/types.ts new file mode 100644 index 000000000000..350526fa2ce7 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/resizing/types.ts @@ -0,0 +1,48 @@ +import type { TimeZoneCalculator } from '../../r1/timezone_calculator'; +import type { DOMMetaData, ViewDataProviderType } from '../../types'; +import type { AppointmentDataAccessor } from '../../utils/data_accessor/appointment_data_accessor'; +import type { AppointmentItemViewModel } from '../../view_model/types'; + +export type Rect = Pick; + +export interface GetAppointmentDateRangeOptions { + handles: { + left: boolean; + right: boolean; + }; + appointmentSettings: AppointmentItemViewModel; + isVerticalGroupedWorkSpace: boolean; + appointmentRect: Rect; + parentAppointmentRect: Rect; + viewDataProvider: ViewDataProviderType; + isDateAndTimeView: boolean; + startDayHour: number; + endDayHour: number; + timeZoneCalculator: TimeZoneCalculator; + dataAccessors: AppointmentDataAccessor; + rtlEnabled?: boolean; + DOMMetaData: DOMMetaData; + viewOffset: number; +} + +export interface CellsInfo { + cellWidth: number; + cellHeight: number; + cellCountInRow: number; +} + +export type GetAppointmentDateRangeOptionsExtended = Omit + & CellsInfo & { + relativeAppointmentRect: Rect; + appointment: { + startDate: Date; + endDate: Date; + isAllDay: boolean; + isOccupiedAllDay: boolean; + }; + }; + +export interface DateRange { + startDate: Date; + endDate: Date; +} diff --git a/packages/devextreme/js/__internal/scheduler/scheduler.ts b/packages/devextreme/js/__internal/scheduler/scheduler.ts index c6123eea7eb7..dd9ad216a892 100644 --- a/packages/devextreme/js/__internal/scheduler/scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/scheduler.ts @@ -71,6 +71,7 @@ import { AppointmentPopup } from './appointment_popup/popup'; import AppointmentCollection, { type AppointmentCollectionOptions } from './appointments/m_appointment_collection'; import type { AppointmentsProperties } from './appointments_new/appointments'; import { Appointments } from './appointments_new/appointments'; +import { getAppointmentDateRange } from './appointments_new/resizing/get_appointment_date_range'; import { getDeltaTime } from './appointments_new/resizing/get_delta_time'; import { getResizableConfig } from './appointments_new/resizing/get_resizable_config'; import { getResizedDates, isStartDateResized } from './appointments_new/resizing/get_resized_dates'; @@ -107,6 +108,7 @@ import type { SafeAppointment, ScrollToGroupValuesOrOptions, ScrollToOptions, TargetedAppointment, ViewCellData, + ViewDataProviderType, ViewType, } from './types'; import { AppointmentAdapter } from './utils/appointment_adapter/appointment_adapter'; @@ -2034,12 +2036,16 @@ class Scheduler extends SchedulerOptionsBaseWidget { private createAppointmentResizableConfig( viewModel: AppointmentItemViewModel, ): ResizableProperties | undefined { - if (!this.allowResizing() || viewModel.allDay || viewModel.skipResizing) { + const isResizeAllowed = viewModel.allDay + ? this.allowAllDayResizing() + : this.allowResizing(); + + if (!isResizeAllowed || viewModel.skipResizing) { return undefined; } const rule = getResizableConfig({ - direction: viewModel.direction === 'horizontal' ? 'horizontal' : 'vertical', + direction: viewModel.allDay || viewModel.direction === 'horizontal' ? 'horizontal' : 'vertical', cellWidth: this._workSpace.getCellWidth(), cellHeight: this._workSpace.getCellHeight(), resizableStep: this._workSpace.positionHelper.getResizableStep(), @@ -2105,10 +2111,27 @@ class Scheduler extends SchedulerOptionsBaseWidget { const settings = this._appointments .getAppointmentSettings($element) as AppointmentItemViewModel; const appointmentData = settings.itemData; - const { info } = settings; - const viewOffset = this.getViewOffsetMs(); - const startDate = this.getEndResizeStartDate(e, appointmentData, info.appointment.startDate); + const dateRange = settings.allDay + ? this.getResizedAllDayDateRange(e, settings) + : this.getResizedTimedDateRange(e, appointmentData, settings.info); + + this.updateResizedAppointment( + appointmentData, + dateRange, + settings.info.sourceAppointment.startDate, + ); + + this.appointmentResizeInitialSize = null; + } + + private getResizedTimedDateRange( + e: AppointmentResizeEvent, + rawAppointment: SafeAppointment, + info: AppointmentItemViewModel['info'], + ): { startDate: Date; endDate: Date } { + const viewOffset = this.getViewOffsetMs(); + const startDate = this.getEndResizeStartDate(e, rawAppointment, info.appointment.startDate); const { endDate } = info.appointment; const dateRange = getResizedDates({ @@ -2126,16 +2149,34 @@ class Scheduler extends SchedulerOptionsBaseWidget { endDayHour: this.option('endDayHour'), }); - this.updateResizedAppointment( - appointmentData, - { - startDate: dateUtilsTs.addOffsets(dateRange.startDate, viewOffset), - endDate: dateUtilsTs.addOffsets(dateRange.endDate, viewOffset), - }, - info.sourceAppointment.startDate, - ); + return { + startDate: dateUtilsTs.addOffsets(dateRange.startDate, viewOffset), + endDate: dateUtilsTs.addOffsets(dateRange.endDate, viewOffset), + }; + } - this.appointmentResizeInitialSize = null; + private getResizedAllDayDateRange( + e: AppointmentResizeEvent, + settings: AppointmentItemViewModel, + ): { startDate: Date; endDate: Date } { + const $element = $(e.element); + + return getAppointmentDateRange({ + handles: e.handles, + appointmentSettings: settings, + isVerticalGroupedWorkSpace: this._workSpace.isVerticalGroupedWorkSpace(), + appointmentRect: getBoundingRect($element.get(0)), + parentAppointmentRect: getBoundingRect($element.parent().get(0)), + viewDataProvider: this._workSpace.viewDataProvider as unknown as ViewDataProviderType, + isDateAndTimeView: isDateAndTimeView(this._workSpace.type), + startDayHour: this.option('startDayHour'), + endDayHour: this.option('endDayHour'), + timeZoneCalculator: this.timeZoneCalculator, + dataAccessors: this._dataAccessors, + rtlEnabled: Boolean(this.option('rtlEnabled')), + DOMMetaData: this._workSpace.getDOMElementsMetaData(), + viewOffset: this.getViewOffsetMs(), + }); } private updateResizedAppointment( From 0a4957a6fb9df30f5b891d5902746dc6bb30e7d9 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:30:58 +0400 Subject: [PATCH 14/16] refactor(scheduler): type resizable test options and drop redundant resize-size reset --- packages/devextreme/js/__internal/scheduler/scheduler.ts | 2 -- .../devextreme/js/__internal/ui/resizable/resizable.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/scheduler.ts b/packages/devextreme/js/__internal/scheduler/scheduler.ts index dd9ad216a892..8e4c38db3110 100644 --- a/packages/devextreme/js/__internal/scheduler/scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/scheduler.ts @@ -2121,8 +2121,6 @@ class Scheduler extends SchedulerOptionsBaseWidget { dateRange, settings.info.sourceAppointment.startDate, ); - - this.appointmentResizeInitialSize = null; } private getResizedTimedDateRange( diff --git a/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts b/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts index 0e94054ab2e0..d346ca28f5d1 100644 --- a/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts +++ b/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts @@ -4,13 +4,13 @@ import { import $ from '@js/core/renderer'; import eventsEngine from '@ts/events/core/m_events_engine'; -import Resizable from './resizable'; +import Resizable, { type ResizableProperties } from './resizable'; const HANDLE_BOTTOM_CLASS = 'dx-resizable-handle-bottom'; const instances: Resizable[] = []; -const createResizable = (options: object = {}): { +const createResizable = (options: Partial = {}): { instance: Resizable; $element: ReturnType; $handle: ReturnType; From 4457b01866df481c7321411450913fbd9f5aa671 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:31:11 +0400 Subject: [PATCH 15/16] fix(scheduler): constrain resize to group bounds and roll back on cancel/failure --- .../appointments_new/appointments.test.ts | 21 ++++++++ .../appointments_new/appointments.ts | 4 ++ .../js/__internal/scheduler/scheduler.ts | 54 ++++++++++++++++++- 3 files changed, 77 insertions(+), 2 deletions(-) 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 d5cb58f6a435..45a761477ccf 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -409,6 +409,27 @@ describe('Appointments', () => { }); }); + describe('Resize', () => { + it('should restore view model geometry on resetAppointmentResize', () => { + const instance = createAppointments(getProperties()); + instance.option('viewModel', [ + mockGridViewModel(defaultAppointmentData, { + sortedIndex: 0, top: 10, left: 20, height: 50, width: 100, + }), + ]); + + const $element = instance.getViewItemBySortedIndex(0)?.$element() as ReturnType; + $element.css({ height: '999px', width: '888px', top: '1px' }); + + instance.resetAppointmentResize($element); + + expect($element.css('height')).toBe('50px'); + expect($element.css('width')).toBe('100px'); + expect($element.css('top')).toBe('10px'); + expect($element.css('left')).toBe('20px'); + }); + }); + describe('Resources', () => { it('should apply resource color', async () => { const instance = createAppointments({ diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index e7898cc10fec..c9d4781a62fa 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -135,6 +135,10 @@ export class Appointments extends DOMComponent this.onAppointmentResizeStart( e as unknown as AppointmentResizeEvent, @@ -2067,6 +2067,50 @@ class Scheduler extends SchedulerOptionsBaseWidget { }; } + private getAppointmentResizableArea( + viewModel: AppointmentItemViewModel, + ): ResizableProperties['area'] { + const groupBounds = this.getResizableGroupBounds(viewModel); + const area = groupBounds ?? this._workSpace.getScrollableContainer(); + + return area as unknown as ResizableProperties['area']; + } + + private getResizableGroupBounds( + viewModel: AppointmentItemViewModel, + ): { left: number; right: number; top: number; bottom: number } | undefined { + const groups = this.getViewOption('groups'); + + if (!groups?.length) { + return undefined; + } + + const coordinates = { left: viewModel.left, top: 0, groupIndex: viewModel.groupIndex }; + + if (viewModel.allDay || this.currentView.type === 'month') { + const bounds = this._workSpace.getGroupBounds(coordinates); + return bounds + ? { + left: bounds.left, right: bounds.right, top: 0, bottom: 0, + } + : undefined; + } + + if ( + VERTICAL_VIEW_TYPES.includes(this.currentView.type) + && this._workSpace.isVerticalGroupedWorkSpace() + ) { + const bounds = this._workSpace.getGroupBounds(coordinates); + return bounds + ? { + left: 0, right: 0, top: bounds.top, bottom: bounds.bottom, + } + : undefined; + } + + return undefined; + } + private onAppointmentResizeStart(e: AppointmentResizeEvent): void { this.appointmentResizeInitialSize = { width: e.width, height: e.height }; (this._appointments as unknown as Appointments).focusResizingAppointment($(e.element)); @@ -2120,6 +2164,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { appointmentData, dateRange, settings.info.sourceAppointment.startDate, + () => (this._appointments as unknown as Appointments).resetAppointmentResize($element), ); } @@ -2181,6 +2226,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { sourceAppointment: SafeAppointment, dateRange: { startDate: Date; endDate: Date }, exceptionStartDate: Date, + onRollback: () => void, ): void { const tz = this.timeZoneCalculator; const gridAdapter = new AppointmentAdapter(sourceAppointment, this._dataAccessors).clone(); @@ -2208,9 +2254,13 @@ class Scheduler extends SchedulerOptionsBaseWidget { data, exceptionStartDate, () => { - this.updateAppointmentCore(sourceAppointment, data).catch(noop); + this.updateAppointmentCore(sourceAppointment, data, onRollback).catch(noop); }, false, + undefined, + undefined, + undefined, + onRollback, ); } From 838d4aec8641266024843f98d8fa0deab48969c9 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:33:33 +0400 Subject: [PATCH 16/16] fix(scheduler): guard appointment resize end against missing settings --- packages/devextreme/js/__internal/scheduler/scheduler.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/devextreme/js/__internal/scheduler/scheduler.ts b/packages/devextreme/js/__internal/scheduler/scheduler.ts index 5445fd179357..29f8a75a162b 100644 --- a/packages/devextreme/js/__internal/scheduler/scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/scheduler.ts @@ -2154,6 +2154,11 @@ class Scheduler extends SchedulerOptionsBaseWidget { const $element = $(e.element); const settings = this._appointments .getAppointmentSettings($element) as AppointmentItemViewModel; + + if (!settings) { + return; + } + const appointmentData = settings.itemData; const dateRange = settings.allDay