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..de84effbf9c6 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_resizing.test.ts @@ -0,0 +1,110 @@ +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 eventsEngine from '@ts/events/core/m_events_engine'; + +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 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); + }); + + 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/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.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.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts index ac8aa5a97391..45a761477ccf 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 = ( @@ -408,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 4a8a1f1c233e..c9d4781a62fa 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 { @@ -122,6 +127,18 @@ export class Appointments extends DOMComponent undefined, }; } @@ -391,6 +409,8 @@ export class Appointments extends DOMComponent ({ + 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..dde9bb19f337 --- /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 correct delta in px for 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/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), + }; +}; 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 b89dd02c0729..29f8a75a162b 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,12 @@ 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'; 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'; @@ -102,6 +108,7 @@ import type { SafeAppointment, ScrollToGroupValuesOrOptions, ScrollToOptions, TargetedAppointment, ViewCellData, + ViewDataProviderType, ViewType, } from './types'; import { AppointmentAdapter } from './utils/appointment_adapter/appointment_adapter'; @@ -355,6 +362,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 +399,8 @@ class Scheduler extends SchedulerOptionsBaseWidget { private appointmentDragController!: AppointmentDragController; + private appointmentResizeInitialSize: { width: number; height: number } | null = null; + appointmentDataSource!: AppointmentDataSource; declare _dataSource: DataSource | undefined; @@ -1356,6 +1377,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 +2033,242 @@ class Scheduler extends SchedulerOptionsBaseWidget { }); } + private createAppointmentResizableConfig( + viewModel: AppointmentItemViewModel, + ): ResizableProperties | undefined { + const isResizeAllowed = viewModel.allDay + ? this.allowAllDayResizing() + : this.allowResizing(); + + if (!isResizeAllowed || viewModel.skipResizing) { + return undefined; + } + + const rule = getResizableConfig({ + direction: viewModel.allDay || 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, + area: this.getAppointmentResizableArea(viewModel), + onCancelByEsc: true, + onResizeStart: (e): void => this.onAppointmentResizeStart( + e as unknown as AppointmentResizeEvent, + ), + onResizeEnd: (e): void => this.onAppointmentResizeEnd( + e as unknown as AppointmentResizeEvent, + ), + }; + } + + 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)); + } + + 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): void { + const $element = $(e.element); + const settings = this._appointments + .getAppointmentSettings($element) as AppointmentItemViewModel; + + if (!settings) { + return; + } + + const appointmentData = settings.itemData; + + const dateRange = settings.allDay + ? this.getResizedAllDayDateRange(e, settings) + : this.getResizedTimedDateRange(e, appointmentData, settings.info); + + this.updateResizedAppointment( + appointmentData, + dateRange, + settings.info.sourceAppointment.startDate, + () => (this._appointments as unknown as Appointments).resetAppointmentResize($element), + ); + } + + 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({ + 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 { + startDate: dateUtilsTs.addOffsets(dateRange.startDate, viewOffset), + endDate: dateUtilsTs.addOffsets(dateRange.endDate, viewOffset), + }; + } + + 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( + sourceAppointment: SafeAppointment, + dateRange: { startDate: Date; endDate: Date }, + exceptionStartDate: Date, + onRollback: () => void, + ): void { + 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; + + this.checkRecurringAppointment( + sourceAppointment, + data, + exceptionStartDate, + () => { + this.updateAppointmentCore(sourceAppointment, data, onRollback).catch(noop); + }, + false, + undefined, + undefined, + undefined, + onRollback, + ); + } + checkRecurringAppointment( rawAppointment: SafeAppointment, singleAppointment: SafeAppointment, 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..d346ca28f5d1 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/resizable/resizable.test.ts @@ -0,0 +1,111 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import eventsEngine from '@ts/events/core/m_events_engine'; + +import Resizable, { type ResizableProperties } from './resizable'; + +const HANDLE_BOTTOM_CLASS = 'dx-resizable-handle-bottom'; + +const instances: Resizable[] = []; + +const createResizable = (options: Partial = {}): { + 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}`); + + instances.push(instance); + + 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(() => { + instances.forEach((instance) => instance.dispose()); + instances.length = 0; + 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..f81f006ecaa3 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,15 @@ class Resizable extends DOMComponent { _resizeAction?: (e: Record) => void; + _resizeCancelAction?: (e: Record) => void; + + _resizeCanceled = false; + _getDefaultOptions(): ResizableProperties { return { ...super._getDefaultOptions(), handles: 'all', + onCancelByEsc: false, // NOTE: does not affect proportional resize step: '1', stepPrecision: 'simple', @@ -145,6 +157,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 +217,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 { @@ -240,6 +259,7 @@ class Resizable extends DOMComponent { return; } + this._resizeCanceled = false; this._toggleResizingClass(true); this._movingSides = this._getMovingSides(e); @@ -263,6 +283,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 +461,10 @@ class Resizable extends DOMComponent { } _dragHandler(e: DragEvent): void { + if (this._resizeCanceled) { + return; + } + const offset = this._getOffset(e); const delta = this._getDeltaByOffset(offset); @@ -673,6 +701,11 @@ class Resizable extends DOMComponent { } _dragEndHandler(e: DxEvent): void { + if (this._resizeCanceled) { + this._resizeCanceled = false; + return; + } + const $element = this.$element(); this._resizeEndAction?.({ @@ -685,6 +718,37 @@ 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._resizeCanceled = true; + 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); + + triggerResizeEvent(this.$element()); + } + _renderWidth(width: number): void { const { minWidth, maxWidth } = this.option(); this.option('width', fitIntoRange(width, minWidth, maxWidth)); @@ -721,8 +785,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':