diff --git a/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts b/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts index 278cdc41434c..0b98e69ecccb 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts @@ -776,20 +776,22 @@ class SchedulerAppointments extends CollectionWidget { const $element = $(e.element); const timeZoneCalculator = this.option('timeZoneCalculator'); + const scale = this.option('scale'); + return getAppointmentDateRange({ handles: e.handles, appointmentSettings: $element.data(APPOINTMENT_SETTINGS_KEY) as any, - isVerticalGroupedWorkSpace: this.option('isVerticalGroupedWorkSpace')(), + isVerticalGroupedWorkSpace: scale.isVerticalGroupedWorkSpace(), appointmentRect: getBoundingRect($element[0]), parentAppointmentRect: getBoundingRect($element.parent()[0]), - viewDataProvider: this.option('getViewDataProvider')(), - isDateAndTimeView: this.option('isDateAndTimeView')(), + getCellDateInfo: scale.getCellDateInfo.bind(scale), + getCellGeometry: scale.getCellGeometry.bind(scale), + isDateAndTimeView: scale.isDateAndTimeView(), startDayHour: this.invoke('getStartDayHour'), endDayHour: this.invoke('getEndDayHour'), timeZoneCalculator, dataAccessors: this.dataAccessors, rtlEnabled: this.option('rtlEnabled'), - DOMMetaData: this.option('getDOMElementsMetaData')(), viewOffset: this.invoke('getViewOffsetMs'), }); } diff --git a/packages/devextreme/js/__internal/scheduler/appointments/resizing/m_core.ts b/packages/devextreme/js/__internal/scheduler/appointments/resizing/m_core.ts index a235bc0515b4..a64599240b7c 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/resizing/m_core.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/resizing/m_core.ts @@ -1,7 +1,7 @@ import { dateUtilsTs } from '@ts/core/utils/date'; import { dateUtils } from '@ts/core/utils/m_date'; -import type { ViewCellData } from '../../types'; +import type { CellDateInfo } from '../../entieties/scale'; import type { CellsInfo, DateRange, @@ -12,25 +12,21 @@ import type { const toMs = dateUtils.dateToMilliseconds; -// NOTE: View data generator shifts all day cell dates by offset -// and return equal start and end dates. const getCellData = ( - { viewDataProvider }: GetAppointmentDateRangeOptionsExtended, + { getCellDateInfo }: GetAppointmentDateRangeOptionsExtended, cellRowIndex: number, cellColumnIndex: number, isOccupiedAllDay: boolean, isAllDay = false, rtlEnabled = false, -): ViewCellData => { - const cellData = viewDataProvider.getCellData( +): CellDateInfo => { + const cellData = getCellDateInfo( cellRowIndex, cellColumnIndex, isOccupiedAllDay, rtlEnabled, - ); - // NOTE: All day appointments occupy day if they start at the beginning of the day, - // but long appointments are not. So for all day appointments endDate === startDate, - // for long appointments endDate = startDate + 1 day. + )!; + if (!isAllDay) { cellData.endDate = dateUtilsTs.addOffsets(cellData.startDate, toMs('day')); } @@ -38,7 +34,7 @@ const getCellData = ( return cellData; }; -const getAppointmentLeftCell = (options: GetAppointmentDateRangeOptionsExtended): ViewCellData => { +const getAppointmentLeftCell = (options: GetAppointmentDateRangeOptionsExtended): CellDateInfo => { const { cellHeight, cellWidth, @@ -165,25 +161,16 @@ const getRelativeAppointmentRect = (appointmentRect: Rect, parentAppointmentRect const getAppointmentCellsInfo = (options: GetAppointmentDateRangeOptions): CellsInfo => { const { appointmentSettings, - isVerticalGroupedWorkSpace, - DOMMetaData, + getCellGeometry, } = 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; + const geometry = getCellGeometry( + appointmentSettings.rowIndex, + appointmentSettings.columnIndex, + Boolean(appointmentSettings.allDay), + ); - return { - cellWidth, - cellHeight, - cellCountInRow, - }; + return geometry ?? { cellWidth: 0, cellHeight: 0, cellCountInRow: 0 }; }; export const getAppointmentDateRange = (options: GetAppointmentDateRangeOptions): DateRange => { diff --git a/packages/devextreme/js/__internal/scheduler/appointments/resizing/types.ts b/packages/devextreme/js/__internal/scheduler/appointments/resizing/types.ts index cc5b123d35a5..6316008d8540 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/resizing/types.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/resizing/types.ts @@ -1,5 +1,5 @@ +import type { CellDateInfo } from '../../entieties/scale'; import type { TimeZoneCalculator } from '../../r1/timezone_calculator'; -import type { ViewDataProviderType } from '../../types'; import type { AppointmentDataAccessor } from '../../utils/data_accessor/appointment_data_accessor'; import type { AppointmentItemViewModel } from '../../view_model/types'; @@ -14,17 +14,23 @@ export interface GetAppointmentDateRangeOptions { isVerticalGroupedWorkSpace: boolean; appointmentRect: Rect; parentAppointmentRect: Rect; - viewDataProvider: ViewDataProviderType; + getCellDateInfo: ( + rowIndex: number, + columnIndex: number, + isAllDay: boolean, + rtlEnabled: boolean, + ) => CellDateInfo | undefined; + getCellGeometry: ( + rowIndex: number, + columnIndex: number, + isAllDay: boolean, + ) => CellsInfo | undefined; isDateAndTimeView: boolean; startDayHour: number; endDayHour: number; timeZoneCalculator: TimeZoneCalculator; dataAccessors: AppointmentDataAccessor; rtlEnabled?: boolean; - DOMMetaData: { - allDayPanelCellsMeta: Rect[]; - dateTableCellsMeta: Rect[][]; - }; viewOffset: number; } diff --git a/packages/devextreme/js/__internal/scheduler/entieties/scale.test.ts b/packages/devextreme/js/__internal/scheduler/entieties/scale.test.ts new file mode 100644 index 000000000000..cf0f6a05eb5d --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/entieties/scale.test.ts @@ -0,0 +1,358 @@ +import { WorkspaceScale } from './scale'; + +const createMockWorkspace = (overrides: Record = {}) => ({ + positionHelper: { + getResizableStep: jest.fn(() => 42), + }, + getDOMElementsMetaData: jest.fn(() => ({ + dateTableCellsMeta: [[{ + left: 0, top: 0, width: 100, height: 50, + }]], + allDayPanelCellsMeta: [{ + left: 0, top: 0, width: 100, height: 30, + }], + })), + viewDataProvider: { + getCellData: jest.fn((rowIndex: number, columnIndex: number) => ({ + startDate: new Date(2024, 0, 1 + columnIndex), + endDate: new Date(2024, 0, 2 + columnIndex), + index: rowIndex * 7 + columnIndex, + })), + }, + isVerticalGroupedWorkSpace: jest.fn(() => false), + type: 'day' as const, + getCellWidth: jest.fn(() => 100), + getCellHeight: jest.fn(() => 50), + option: jest.fn((name: string) => { + const options = { cellDuration: 30, startDayHour: 9, endDayHour: 18 }; + return options[name]; + }), + _getGroupCount: jest.fn(() => 2), + needRecalculateResizableArea: jest.fn(() => true), + getGroupBounds: jest.fn(() => ({ + left: 10, right: 200, top: 5, bottom: 300, + })), + getAgendaVerticalStepHeight: jest.fn(() => 80), + supportAllDayRow: jest.fn(() => true), + isGroupedByDate: jest.fn(() => false), + ...overrides, +}); + +describe('WorkspaceScale', () => { + describe('getResizableStep', () => { + it('should delegate to workspace positionHelper', () => { + const workspace = createMockWorkspace(); + const scale = new WorkspaceScale(() => workspace); + + expect(scale.getResizableStep()).toBe(42); + expect(workspace.positionHelper.getResizableStep).toHaveBeenCalled(); + }); + + it('should return 0 when workspace is undefined', () => { + const scale = new WorkspaceScale(() => undefined); + + expect(scale.getResizableStep()).toBe(0); + }); + }); + + describe('getDOMElementsMetaData', () => { + it('should delegate to workspace', () => { + const workspace = createMockWorkspace(); + const scale = new WorkspaceScale(() => workspace); + + const meta = scale.getDOMElementsMetaData(); + + expect(meta).toEqual({ + dateTableCellsMeta: [[{ + left: 0, top: 0, width: 100, height: 50, + }]], + allDayPanelCellsMeta: [{ + left: 0, top: 0, width: 100, height: 30, + }], + }); + expect(workspace.getDOMElementsMetaData).toHaveBeenCalled(); + }); + + it('should return undefined when workspace is undefined', () => { + const scale = new WorkspaceScale(() => undefined); + + expect(scale.getDOMElementsMetaData()).toBeUndefined(); + }); + }); + + describe('viewDataProvider', () => { + it('should return workspace viewDataProvider', () => { + const workspace = createMockWorkspace(); + const scale = new WorkspaceScale(() => workspace); + + expect(scale.viewDataProvider).toBe(workspace.viewDataProvider); + }); + + it('should return undefined when workspace is undefined', () => { + const scale = new WorkspaceScale(() => undefined); + + expect(scale.viewDataProvider).toBeUndefined(); + }); + }); + + describe('getCellDateInfo', () => { + it('should return startDate, endDate, and index from viewDataProvider', () => { + const workspace = createMockWorkspace(); + const scale = new WorkspaceScale(() => workspace); + + const info = scale.getCellDateInfo(0, 2, false, false); + + expect(info).toEqual({ + startDate: new Date(2024, 0, 3), + endDate: new Date(2024, 0, 4), + index: 2, + }); + expect(workspace.viewDataProvider.getCellData).toHaveBeenCalledWith(0, 2, false, false); + }); + + it('should pass isAllDay and rtlEnabled to viewDataProvider', () => { + const workspace = createMockWorkspace(); + const scale = new WorkspaceScale(() => workspace); + + scale.getCellDateInfo(1, 3, true, true); + + expect(workspace.viewDataProvider.getCellData).toHaveBeenCalledWith(1, 3, true, true); + }); + + it('should return undefined when workspace is undefined', () => { + const scale = new WorkspaceScale(() => undefined); + + expect(scale.getCellDateInfo(0, 0, false, false)).toBeUndefined(); + }); + }); + + describe('getCellGeometry', () => { + it('should return cellWidth, cellHeight, cellCountInRow from dateTable', () => { + const workspace = createMockWorkspace({ + getDOMElementsMetaData: jest.fn(() => ({ + dateTableCellsMeta: [[ + { + left: 0, top: 0, width: 120, height: 60, + }, + { + left: 120, top: 0, width: 120, height: 60, + }, + ]], + allDayPanelCellsMeta: [{ + left: 0, top: 0, width: 120, height: 30, + }], + })), + }); + const scale = new WorkspaceScale(() => workspace); + + const geometry = scale.getCellGeometry(0, 0, false); + + expect(geometry).toEqual({ + cellWidth: 120, + cellHeight: 60, + cellCountInRow: 2, + }); + }); + + it('should use allDayPanelCellsMeta when isAllDay and not vertical grouped', () => { + const workspace = createMockWorkspace({ + getDOMElementsMetaData: jest.fn(() => ({ + dateTableCellsMeta: [[{ + left: 0, top: 0, width: 100, height: 50, + }]], + allDayPanelCellsMeta: [ + { + left: 0, top: 0, width: 200, height: 40, + }, + { + left: 200, top: 0, width: 200, height: 40, + }, + ], + })), + isVerticalGroupedWorkSpace: jest.fn(() => false), + }); + const scale = new WorkspaceScale(() => workspace); + + const geometry = scale.getCellGeometry(0, 1, true); + + expect(geometry).toEqual({ + cellWidth: 200, + cellHeight: 40, + cellCountInRow: 2, + }); + }); + + it('should use dateTableCellsMeta when isAllDay but vertical grouped', () => { + const workspace = createMockWorkspace({ + isVerticalGroupedWorkSpace: jest.fn(() => true), + }); + const scale = new WorkspaceScale(() => workspace); + + const geometry = scale.getCellGeometry(0, 0, true); + + expect(geometry).toEqual({ + cellWidth: 100, + cellHeight: 50, + cellCountInRow: 1, + }); + }); + + it('should return undefined when workspace is undefined', () => { + const scale = new WorkspaceScale(() => undefined); + + expect(scale.getCellGeometry(0, 0, false)).toBeUndefined(); + }); + }); + + describe('isVerticalGroupedWorkSpace', () => { + it('should delegate to workspace', () => { + const workspace = createMockWorkspace({ + isVerticalGroupedWorkSpace: jest.fn(() => true), + }); + const scale = new WorkspaceScale(() => workspace); + + expect(scale.isVerticalGroupedWorkSpace()).toBe(true); + }); + + it('should return false for non-vertical grouping', () => { + const workspace = createMockWorkspace(); + const scale = new WorkspaceScale(() => workspace); + + expect(scale.isVerticalGroupedWorkSpace()).toBe(false); + }); + }); + + describe('isDateAndTimeView', () => { + it('should return true for day view', () => { + const workspace = createMockWorkspace({ type: 'day' }); + const scale = new WorkspaceScale(() => workspace); + + expect(scale.isDateAndTimeView()).toBe(true); + }); + + it('should return false for month view', () => { + const workspace = createMockWorkspace({ type: 'month' }); + const scale = new WorkspaceScale(() => workspace); + + expect(scale.isDateAndTimeView()).toBe(false); + }); + + it('should return false for timelineMonth view', () => { + const workspace = createMockWorkspace({ type: 'timelineMonth' }); + const scale = new WorkspaceScale(() => workspace); + + expect(scale.isDateAndTimeView()).toBe(false); + }); + + it('should return true for week view', () => { + const workspace = createMockWorkspace({ type: 'week' }); + const scale = new WorkspaceScale(() => workspace); + + expect(scale.isDateAndTimeView()).toBe(true); + }); + }); + + describe('workspace replacement', () => { + it('should follow workspace getter to current workspace', () => { + const workspaceA = createMockWorkspace({ + positionHelper: { getResizableStep: jest.fn(() => 10) }, + }); + const workspaceB = createMockWorkspace({ + positionHelper: { getResizableStep: jest.fn(() => 20) }, + }); + + let current: ReturnType = workspaceA; + const scale = new WorkspaceScale(() => current); + + expect(scale.getResizableStep()).toBe(10); + + current = workspaceB; + + expect(scale.getResizableStep()).toBe(20); + }); + }); + + describe('getCellWidth / getCellHeight', () => { + it('should delegate to workspace', () => { + const workspace = createMockWorkspace(); + const scale = new WorkspaceScale(() => workspace); + + expect(scale.getCellWidth()).toBe(100); + expect(scale.getCellHeight()).toBe(50); + }); + + it('should return 0 when workspace is undefined', () => { + const scale = new WorkspaceScale(() => undefined); + + expect(scale.getCellWidth()).toBe(0); + expect(scale.getCellHeight()).toBe(0); + }); + }); + + describe('cellDuration / startDayHour / endDayHour', () => { + it('should read from workspace options', () => { + const workspace = createMockWorkspace(); + const scale = new WorkspaceScale(() => workspace); + + expect(scale.cellDuration).toBe(30); + expect(scale.startDayHour).toBe(9); + expect(scale.endDayHour).toBe(18); + }); + }); + + describe('groupCount', () => { + it('should delegate to workspace _getGroupCount', () => { + const workspace = createMockWorkspace(); + const scale = new WorkspaceScale(() => workspace); + + expect(scale.groupCount).toBe(2); + }); + }); + + describe('needRecalculateResizableArea', () => { + it('should delegate to workspace', () => { + const workspace = createMockWorkspace(); + const scale = new WorkspaceScale(() => workspace); + + expect(scale.needRecalculateResizableArea()).toBe(true); + }); + }); + + describe('getGroupBounds', () => { + it('should delegate to workspace', () => { + const workspace = createMockWorkspace(); + const scale = new WorkspaceScale(() => workspace); + + const bounds = scale.getGroupBounds({ groupIndex: 0 }); + + expect(bounds).toEqual({ + left: 10, right: 200, top: 5, bottom: 300, + }); + }); + + it('should return undefined when workspace is undefined', () => { + const scale = new WorkspaceScale(() => undefined); + + expect(scale.getGroupBounds({})).toBeUndefined(); + }); + }); + + describe('agendaVerticalStepHeight', () => { + it('should delegate to workspace', () => { + const workspace = createMockWorkspace(); + const scale = new WorkspaceScale(() => workspace); + + expect(scale.agendaVerticalStepHeight).toBe(80); + }); + }); + + describe('supportAllDayRow / isGroupedByDate', () => { + it('should delegate to workspace', () => { + const workspace = createMockWorkspace(); + const scale = new WorkspaceScale(() => workspace); + + expect(scale.supportAllDayRow).toBe(true); + expect(scale.isGroupedByDate).toBe(false); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/entieties/scale.ts b/packages/devextreme/js/__internal/scheduler/entieties/scale.ts new file mode 100644 index 000000000000..87c22491609c --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/entieties/scale.ts @@ -0,0 +1,226 @@ +import type { dxElementWrapper } from '@js/core/renderer'; + +import type { CellsInfo, Rect } from '../appointments/resizing/types'; +import { isDateAndTimeView } from '../r1/utils/index'; +import type { ViewDataProviderType } from '../types'; + +export interface DOMElementsMetaData { + dateTableCellsMeta: Rect[][]; + allDayPanelCellsMeta: Rect[]; +} + +export interface CellDateInfo { + startDate: Date; + endDate: Date; + index: number; +} + +export interface GroupBounds { + left: number; + right: number; + top: number; + bottom: number; +} + +export interface CellCoordinates { + left: number; + top: number; +} + +export interface GroupCoordinates { + groupIndex: number; +} + +interface Workspace { + positionHelper: { getResizableStep: () => number }; + getDOMElementsMetaData: () => DOMElementsMetaData; + viewDataProvider: ViewDataProviderType; + isVerticalGroupedWorkSpace: () => boolean; + type: string; + getCellWidth: () => number; + getCellHeight: () => number; + option: (name: string) => unknown; + _getGroupCount: () => number; + needRecalculateResizableArea: () => boolean; + getGroupBounds: (coordinates: GroupCoordinates) => GroupBounds; + getAgendaVerticalStepHeight: () => number; + supportAllDayRow: () => boolean; + isGroupedByDate: () => boolean; + getDroppableCell: () => dxElementWrapper; + getCellByCoordinates: (coordinates: CellCoordinates, isAllDay: boolean) => dxElementWrapper; + removeDroppableCellClass: () => void; +} + +export interface ScaleGeometry { + getCellWidth: () => number; + getCellHeight: () => number; + getResizableStep: () => number; + getCellGeometry: ( + rowIndex: number, + columnIndex: number, + isAllDay: boolean, + ) => CellsInfo | undefined; + getDOMElementsMetaData: () => DOMElementsMetaData | undefined; +} + +export interface ScaleData { + readonly viewDataProvider: ViewDataProviderType | undefined; + getCellDateInfo: ( + rowIndex: number, + columnIndex: number, + isAllDay: boolean, + rtlEnabled: boolean, + ) => CellDateInfo | undefined; + cellDuration: number; + startDayHour: number; + endDayHour: number; + groupCount: number; + agendaVerticalStepHeight: number; + supportAllDayRow: boolean; + isGroupedByDate: boolean; + isVerticalGroupedWorkSpace: () => boolean; + isDateAndTimeView: () => boolean; +} + +export interface ScaleDragDrop { + getDroppableCell: () => dxElementWrapper | undefined; + getCellByCoordinates: ( + coordinates: CellCoordinates, isAllDay: boolean, + ) => dxElementWrapper | undefined; + removeDroppableCellClass: () => void; +} + +export interface ScaleInteraction { + needRecalculateResizableArea: () => boolean; + getGroupBounds: (coordinates: GroupCoordinates) => GroupBounds | undefined; +} + +export type Scale = ScaleGeometry & ScaleData & ScaleDragDrop & ScaleInteraction; + +export class WorkspaceScale implements Scale { + private readonly getWorkspace: () => Workspace | undefined; + + constructor(getWorkspace: () => Workspace | undefined) { + this.getWorkspace = getWorkspace; + } + + get viewDataProvider(): ViewDataProviderType | undefined { + return this.getWorkspace()?.viewDataProvider; + } + + getResizableStep(): number { + const workspace = this.getWorkspace(); + return workspace ? workspace.positionHelper.getResizableStep() : 0; + } + + getDOMElementsMetaData(): DOMElementsMetaData | undefined { + return this.getWorkspace()?.getDOMElementsMetaData(); + } + + getCellDateInfo( + rowIndex: number, + columnIndex: number, + isAllDay: boolean, + rtlEnabled: boolean, + ): CellDateInfo | undefined { + const workspace = this.getWorkspace(); + if (!workspace) return undefined; + const { viewDataProvider } = workspace; + const cellData = viewDataProvider.getCellData(rowIndex, columnIndex, isAllDay, rtlEnabled); + return { + startDate: cellData.startDate, + endDate: cellData.endDate, + index: cellData.index, + }; + } + + getCellGeometry( + rowIndex: number, + columnIndex: number, + isAllDay: boolean, + ): CellsInfo | undefined { + const workspace = this.getWorkspace(); + if (!workspace) return undefined; + const meta = workspace.getDOMElementsMetaData(); + const isVertical = workspace.isVerticalGroupedWorkSpace(); + const metaTable = isAllDay && !isVertical + ? [meta.allDayPanelCellsMeta] + : meta.dateTableCellsMeta; + const row = metaTable[rowIndex]; + if (!row?.[columnIndex]) return undefined; + return { + cellWidth: row[columnIndex].width, + cellHeight: row[columnIndex].height, + cellCountInRow: row.length, + }; + } + + getCellWidth(): number { + return this.getWorkspace()?.getCellWidth() ?? 0; + } + + getCellHeight(): number { + return this.getWorkspace()?.getCellHeight() ?? 0; + } + + get cellDuration(): number { + return (this.getWorkspace()?.option('cellDuration') as number) ?? 30; + } + + get startDayHour(): number { + return (this.getWorkspace()?.option('startDayHour') as number) ?? 0; + } + + get endDayHour(): number { + return (this.getWorkspace()?.option('endDayHour') as number) ?? 24; + } + + get groupCount(): number { + return this.getWorkspace()?._getGroupCount() ?? 0; + } + + needRecalculateResizableArea(): boolean { + return this.getWorkspace()?.needRecalculateResizableArea() ?? false; + } + + getGroupBounds(coordinates: GroupCoordinates): GroupBounds | undefined { + return this.getWorkspace()?.getGroupBounds(coordinates); + } + + get agendaVerticalStepHeight(): number { + return this.getWorkspace()?.getAgendaVerticalStepHeight() ?? 0; + } + + get supportAllDayRow(): boolean { + return this.getWorkspace()?.supportAllDayRow() ?? false; + } + + get isGroupedByDate(): boolean { + return this.getWorkspace()?.isGroupedByDate() ?? false; + } + + getDroppableCell(): dxElementWrapper | undefined { + return this.getWorkspace()?.getDroppableCell(); + } + + getCellByCoordinates( + coordinates: CellCoordinates, + isAllDay: boolean, + ): dxElementWrapper | undefined { + return this.getWorkspace()?.getCellByCoordinates(coordinates, isAllDay); + } + + removeDroppableCellClass(): void { + this.getWorkspace()?.removeDroppableCellClass(); + } + + isVerticalGroupedWorkSpace(): boolean { + return this.getWorkspace()?.isVerticalGroupedWorkSpace() ?? false; + } + + isDateAndTimeView(): boolean { + const workspace = this.getWorkspace(); + if (!workspace) return false; + return isDateAndTimeView(workspace.type as Parameters[0]); + } +} diff --git a/packages/devextreme/js/__internal/scheduler/m_appointment_drag_behavior.ts b/packages/devextreme/js/__internal/scheduler/m_appointment_drag_behavior.ts index 7d7b4a787f75..513f2cc7204c 100644 --- a/packages/devextreme/js/__internal/scheduler/m_appointment_drag_behavior.ts +++ b/packages/devextreme/js/__internal/scheduler/m_appointment_drag_behavior.ts @@ -10,7 +10,7 @@ import type { AppointmentViewModelPlain } from './view_model/types'; const APPOINTMENT_ITEM_CLASS = 'dx-scheduler-appointment'; export default class AppointmentDragBehavior { - workspace = this.scheduler._workSpace; + scale = this.scheduler._scale; appointments = this.scheduler._appointments; @@ -62,15 +62,15 @@ export default class AppointmentDragBehavior { const container = this.appointments._getAppointmentContainer(isAllDay); container.append(element); - const $targetCell = this.workspace.getDroppableCell(); - const $dragCell = this.workspace.getCellByCoordinates(this.initialPosition, isAllDay); + const $targetCell = this.scale.getDroppableCell(); + const $dragCell = this.scale.getCellByCoordinates(this.initialPosition, isAllDay); this.appointments.notifyObserver('updateAppointmentAfterDrag', { event, element, rawAppointment, - isDropToTheSameCell: $targetCell.is($dragCell), - isDropToSelfScheduler: $targetCell.length > 0, + isDropToTheSameCell: $targetCell?.is($dragCell), + isDropToSelfScheduler: ($targetCell?.length ?? 0) > 0, }); } @@ -150,7 +150,6 @@ export default class AppointmentDragBehavior { } } - // NOTE: event.cancel may be promise or different type, so we need strict check here. if (e.cancel === true) { options.onDragCancel(e); } @@ -212,6 +211,6 @@ export default class AppointmentDragBehavior { removeDroppableClasses() { this.appointments._removeDragSourceClassFromDraggedAppointment(); - this.workspace.removeDroppableCellClass(); + this.scale.removeDroppableCellClass(); } } diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index de3f4a0fe049..7439f7e080ad 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -42,6 +42,8 @@ import { ACTION_TO_APPOINTMENT, AppointmentPopup as AppointmentLegacyPopup } fro import { AppointmentPopup } from './appointment_popup/m_popup'; import AppointmentCollection from './appointments/m_appointment_collection'; import NotifyScheduler from './base/m_widget_notify_scheduler'; +import type { Scale } from './entieties/scale'; +import { WorkspaceScale } from './entieties/scale'; import { SchedulerHeader } from './header/m_header'; import type { HeaderOptions } from './header/types'; import { CompactAppointmentsHelper } from './m_compact_appointments_helper'; @@ -56,7 +58,6 @@ import { excludeFromRecurrence, getToday, isAppointmentTakesAllDay, - isDateAndTimeView, isTimelineView, } from './r1/utils/index'; import { validateRRule } from './recurrence/validate_rule'; @@ -171,6 +172,8 @@ class Scheduler extends SchedulerOptionsBaseWidget { _workSpace: any; + private _scale!: Scale; + private header?: SchedulerHeader; _appointments: any; @@ -735,6 +738,8 @@ class Scheduler extends SchedulerOptionsBaseWidget { super._init(); + this._scale = new WorkspaceScale(() => this._workSpace); + this.initAllDayPanel(); // @ts-expect-error @@ -1255,11 +1260,8 @@ class Scheduler extends SchedulerOptionsBaseWidget { groups: this.getViewOption('groups'), groupByDate: this.getViewOption('groupByDate'), timeZoneCalculator: this.timeZoneCalculator, - getResizableStep: () => (this._workSpace ? this._workSpace.positionHelper.getResizableStep() : 0), - getDOMElementsMetaData: () => this._workSpace?.getDOMElementsMetaData(), - getViewDataProvider: () => this._workSpace?.viewDataProvider, - isVerticalGroupedWorkSpace: () => this._workSpace.isVerticalGroupedWorkSpace(), - isDateAndTimeView: () => isDateAndTimeView(this._workSpace.type), + scale: this._scale, + getResizableStep: () => this._scale.getResizableStep(), onContentReady: () => { this._workSpace?.option('allDayExpanded', this.isAllDayExpanded()); }, diff --git a/packages/devextreme/js/__internal/scheduler/m_subscribes.ts b/packages/devextreme/js/__internal/scheduler/m_subscribes.ts index ceb9b6ae1979..55451c9a9880 100644 --- a/packages/devextreme/js/__internal/scheduler/m_subscribes.ts +++ b/packages/devextreme/js/__internal/scheduler/m_subscribes.ts @@ -47,7 +47,7 @@ const subscribes = { }, isGroupedByDate() { - return this.getWorkSpace().isGroupedByDate(); + return this._scale.isGroupedByDate; }, showAppointmentTooltip(options: { data: SafeAppointment; target: dxElementWrapper }) { @@ -83,13 +83,11 @@ const subscribes = { event, element, rawAppointment, isDropToTheSameCell, isDropToSelfScheduler, }) { const { info } = utils.dataAccessors.getAppointmentSettings(element) as AppointmentItemViewModel; - // NOTE: enrich target appointment with additional data from the source - // in case of one appointment of series will change const targetedRawAppointment = extend({}, rawAppointment, this.getUpdatedData(rawAppointment)); const fromAllDay = Boolean(rawAppointment.allDay); const toAllDay = Boolean(targetedRawAppointment.allDay); - const isDropBetweenAllDay = this._workSpace.supportAllDayRow() && fromAllDay !== toAllDay; + const isDropBetweenAllDay = this._scale.supportAllDayRow && fromAllDay !== toAllDay; const isDragAndDropBetweenComponents = event.fromComponent !== event.toComponent; @@ -97,7 +95,6 @@ const subscribes = { this._appointments.moveAppointmentBack(event); }; if (!isDropToSelfScheduler && isDragAndDropBetweenComponents) { - // drop between schedulers return; } @@ -127,7 +124,6 @@ const subscribes = { ...targetedAppointmentRaw, } as TargetedAppointment; const adapter = new AppointmentAdapter(targetedAppointment, this._dataAccessors); - // pull out time zone converting from appointment adapter for knockout (T947938) const startDate = targetedAppointment.displayStartDate || this.timeZoneCalculator.createDate(adapter.startDate, 'toGrid'); const endDate = targetedAppointment.displayEndDate || this.timeZoneCalculator.createDate(adapter.endDate, 'toGrid'); const formatType = format ?? getFormatType(startDate, endDate, adapter.allDay, this.currentView.type !== 'month'); @@ -144,23 +140,27 @@ const subscribes = { if (groups?.length) { if (allDay || this.currentView.type === 'month') { - const horizontalGroupBounds = this._workSpace.getGroupBounds(options.coordinates); - return { - left: horizontalGroupBounds.left, - right: horizontalGroupBounds.right, - top: 0, - bottom: 0, - }; + const horizontalGroupBounds = this._scale.getGroupBounds(options.coordinates); + if (horizontalGroupBounds) { + return { + left: horizontalGroupBounds.left, + right: horizontalGroupBounds.right, + top: 0, + bottom: 0, + }; + } } - if (!allDay && VERTICAL_VIEW_TYPES.includes(this.currentView.type) && this._workSpace.isVerticalGroupedWorkSpace()) { - const verticalGroupBounds = this._workSpace.getGroupBounds(options.coordinates); - return { - left: 0, - right: 0, - top: verticalGroupBounds.top, - bottom: verticalGroupBounds.bottom, - }; + if (!allDay && VERTICAL_VIEW_TYPES.includes(this.currentView.type) && this._scale.isVerticalGroupedWorkSpace()) { + const verticalGroupBounds = this._scale.getGroupBounds(options.coordinates); + if (verticalGroupBounds) { + return { + left: 0, + right: 0, + top: verticalGroupBounds.top, + bottom: verticalGroupBounds.bottom, + }; + } } } @@ -168,7 +168,7 @@ const subscribes = { }, needRecalculateResizableArea() { - return this.getWorkSpace().needRecalculateResizableArea(); + return this._scale.needRecalculateResizableArea(); }, isAllDay(appointmentData): boolean { @@ -179,21 +179,21 @@ const subscribes = { return getDeltaTime(e, initialSize, { viewType: this.currentView.type, cellSize: { - width: this.getWorkSpace().getCellWidth(), - height: this.getWorkSpace().getCellHeight(), + width: this._scale.getCellWidth(), + height: this._scale.getCellHeight(), }, - cellDurationInMinutes: this.getWorkSpace().option('cellDuration'), - resizableStep: this.getWorkSpace().positionHelper.getResizableStep(), + cellDurationInMinutes: this._scale.cellDuration, + resizableStep: this._scale.getResizableStep(), isAllDayPanel: isAllDay(this, itemData), }); }, getCellWidth() { - return this.getWorkSpace().getCellWidth(); + return this._scale.getCellWidth(); }, getCellHeight() { - return this.getWorkSpace().getCellHeight(); + return this._scale.getCellHeight(); }, needCorrectAppointmentDates() { @@ -229,7 +229,7 @@ const subscribes = { }, getGroupCount() { - return this._workSpace._getGroupCount(); + return this._scale.groupCount; }, mapAppointmentFields(config) { @@ -252,7 +252,7 @@ const subscribes = { }, getAgendaVerticalStepHeight() { - return this.getWorkSpace().getAgendaVerticalStepHeight(); + return this._scale.agendaVerticalStepHeight; }, getAgendaDuration() { @@ -276,11 +276,11 @@ const subscribes = { }, getEndDayHour() { - return this._workSpace.option('endDayHour') || this.option('endDayHour'); + return this._scale.endDayHour || this.option('endDayHour'); }, getStartDayHour() { - return this._workSpace.option('startDayHour') || this.option('startDayHour'); + return this._scale.startDayHour || this.option('startDayHour'); }, getViewOffsetMs() { @@ -292,7 +292,7 @@ const subscribes = { }, removeDroppableCellClass() { - this._workSpace.removeDroppableCellClass(); + this._scale.removeDroppableCellClass(); }, } as const; diff --git a/packages/devextreme/playground/tests/scheduler-resize.spec.ts b/packages/devextreme/playground/tests/scheduler-resize.spec.ts new file mode 100644 index 000000000000..b9b9de270c2c --- /dev/null +++ b/packages/devextreme/playground/tests/scheduler-resize.spec.ts @@ -0,0 +1,163 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Scheduler appointment resize', () => { + test.beforeEach(async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + + await page.goto('/demos/Scheduler/Editing/'); + await page.waitForSelector('.dx-scheduler-appointment', { timeout: 10000 }); + }); + + test('appointments render with correct geometry', async ({ page }) => { + const appointments = page.locator('.dx-scheduler-appointment'); + + const count = await appointments.count(); + expect(count).toBeGreaterThan(0); + + for (let i = 0; i < Math.min(count, 5); i++) { + const box = await appointments.nth(i).boundingBox(); + expect(box).not.toBeNull(); + expect(box!.width).toBeGreaterThan(0); + expect(box!.height).toBeGreaterThan(0); + } + }); + + test('appointment has resize handles', async ({ page }) => { + const appointment = page.locator('.dx-scheduler-appointment').first(); + await appointment.hover(); + + const topHandle = appointment.locator('.dx-resizable-handle-top'); + const bottomHandle = appointment.locator('.dx-resizable-handle-bottom'); + + await expect(topHandle).toBeVisible(); + await expect(bottomHandle).toBeVisible(); + }); + + test('resize appointment by dragging bottom handle changes height', async ({ page }) => { + const appointment = page.locator('.dx-scheduler-appointment').first(); + await appointment.hover(); + + const boxBefore = await appointment.boundingBox(); + expect(boxBefore).not.toBeNull(); + + const bottomHandle = appointment.locator('.dx-resizable-handle-bottom'); + const handleBox = await bottomHandle.boundingBox(); + expect(handleBox).not.toBeNull(); + + const cellHeight = await page.locator('.dx-scheduler-date-table-cell').first().evaluate( + (el) => el.getBoundingClientRect().height, + ); + + await bottomHandle.hover(); + await page.mouse.down(); + await page.mouse.move( + handleBox!.x + handleBox!.width / 2, + handleBox!.y + cellHeight, + { steps: 5 }, + ); + await page.mouse.up(); + + const boxAfter = await appointment.boundingBox(); + expect(boxAfter).not.toBeNull(); + expect(boxAfter!.height).toBeGreaterThan(boxBefore!.height); + }); + + test('resize snaps to cell boundaries', async ({ page }) => { + const appointment = page.locator('.dx-scheduler-appointment').first(); + await appointment.hover(); + + const cellHeight = await page.locator('.dx-scheduler-date-table-cell').first().evaluate( + (el) => el.getBoundingClientRect().height, + ); + + const bottomHandle = appointment.locator('.dx-resizable-handle-bottom'); + const handleBox = await bottomHandle.boundingBox(); + expect(handleBox).not.toBeNull(); + + await bottomHandle.hover(); + await page.mouse.down(); + await page.mouse.move( + handleBox!.x + handleBox!.width / 2, + handleBox!.y + cellHeight * 2, + { steps: 5 }, + ); + await page.mouse.up(); + + const boxAfter = await appointment.boundingBox(); + expect(boxAfter).not.toBeNull(); + const heightRatio = boxAfter!.height / cellHeight; + expect(Math.abs(heightRatio - Math.round(heightRatio))).toBeLessThan(0.15); + }); + + test('appointment position aligns with cell grid after resize', async ({ page }) => { + const appointment = page.locator('.dx-scheduler-appointment').first(); + await appointment.hover(); + + const boxBefore = await appointment.boundingBox(); + expect(boxBefore).not.toBeNull(); + + const cells = page.locator('.dx-scheduler-date-table-cell'); + const firstCellBox = await cells.first().boundingBox(); + expect(firstCellBox).not.toBeNull(); + + expect(boxBefore!.left).toBeGreaterThanOrEqual(firstCellBox!.left - 1); + + const bottomHandle = appointment.locator('.dx-resizable-handle-bottom'); + const handleBox = await bottomHandle.boundingBox(); + + const cellHeight = firstCellBox!.height; + await bottomHandle.hover(); + await page.mouse.down(); + await page.mouse.move( + handleBox!.x + handleBox!.width / 2, + handleBox!.y + cellHeight, + { steps: 5 }, + ); + await page.mouse.up(); + + const boxAfter = await appointment.boundingBox(); + expect(boxAfter).not.toBeNull(); + expect(boxAfter!.left).toBeCloseTo(boxBefore!.left, 0); + }); + + test('container resize triggers appointment repositioning', async ({ page }) => { + const appointment = page.locator('.dx-scheduler-appointment').first(); + const boxBefore = await appointment.boundingBox(); + expect(boxBefore).not.toBeNull(); + + await page.setViewportSize({ width: 800, height: 600 }); + await page.waitForTimeout(500); + + const boxAfter = await appointment.boundingBox(); + expect(boxAfter).not.toBeNull(); + expect(boxAfter!.width).not.toBe(boxBefore!.width); + expect(boxAfter!.height).toBeGreaterThan(0); + expect(boxAfter!.width).toBeGreaterThan(0); + }); + + test('drag appointment to different cell updates position', async ({ page }) => { + const appointment = page.locator('.dx-scheduler-appointment').first(); + const boxBefore = await appointment.boundingBox(); + expect(boxBefore).not.toBeNull(); + + const cellHeight = await page.locator('.dx-scheduler-date-table-cell').first().evaluate( + (el) => el.getBoundingClientRect().height, + ); + + await appointment.hover(); + await page.mouse.down(); + await page.mouse.move( + boxBefore!.x + boxBefore!.width / 2, + boxBefore!.y + cellHeight * 2, + { steps: 10 }, + ); + await page.mouse.up(); + + await page.waitForTimeout(300); + + const boxAfter = await appointment.boundingBox(); + expect(boxAfter).not.toBeNull(); + expect(Math.abs(boxAfter!.y - boxBefore!.y)).toBeGreaterThan(cellHeight * 0.5); + }); +});