diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.recalculation.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.recalculation.test.ts deleted file mode 100644 index 13ffa2259716..000000000000 --- a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.recalculation.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { - afterEach, beforeEach, describe, expect, it, -} from '@jest/globals'; -import $ from '@js/core/renderer'; - -import fx from '../../../common/core/animation/fx'; -import CustomStore from '../../../data/custom_store'; -import { createScheduler } from './__mock__/create_scheduler'; -import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; - -const CLASSES = { - scheduler: 'dx-scheduler', - workSpace: 'dx-scheduler-work-space', -}; - -describe('Workspace Recalculation with Async Templates (T661335)', () => { - beforeEach(() => { - fx.off = true; - setupSchedulerTestEnvironment({ height: 600 }); - }); - - afterEach(() => { - const $scheduler = $(document.querySelector(`.${CLASSES.scheduler}`)); - // @ts-expect-error - $scheduler.dxScheduler('dispose'); - document.body.innerHTML = ''; - fx.off = false; - }); - - it('should not duplicate workspace elements when resources are loaded asynchronously (T661335)', async () => { - const { scheduler, container } = await createScheduler({ - templatesRenderAsynchronously: true, - currentView: 'day', - views: ['day'], - groups: ['owner'], - resources: [ - { - fieldExpr: 'owner', - dataSource: [{ id: 1, text: 'Owner 1' }], - }, - { - fieldExpr: 'room', - dataSource: new CustomStore({ - load(): Promise { - return new Promise((resolve) => { - setTimeout(() => { - resolve([{ id: 1, text: 'Room 1', color: '#ff0000' }]); - }); - }); - }, - }), - }, - ], - dataSource: [ - { - text: 'Meeting in Room 1', - startDate: new Date(2017, 4, 25, 9, 0), - endDate: new Date(2017, 4, 25, 10, 0), - roomId: 1, - }, - ], - startDayHour: 9, - currentDate: new Date(2017, 4, 25), - height: 600, - }); - - scheduler.option('groups', ['room']); - - await new Promise((r) => { setTimeout(r); }); - - const $workSpaces = $(container).find(`.${CLASSES.workSpace}`); - const $groupHeader = $(container).find('.dx-scheduler-group-header'); - - expect($workSpaces.length).toBe(1); - - expect($groupHeader.length).toBeGreaterThan(0); - expect($groupHeader.text()).toContain('Room 1'); - }); -}); diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts new file mode 100644 index 000000000000..65f1dcc99b0c --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts @@ -0,0 +1,303 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import { getWidth } from '@js/core/utils/size'; + +import fx from '../../../common/core/animation/fx'; +import CustomStore from '../../../data/custom_store'; +import { createScheduler } from './__mock__/create_scheduler'; +import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; + +const CLASSES = { + scheduler: 'dx-scheduler', + workSpace: 'dx-scheduler-work-space', +}; + +describe('Workspace', () => { + describe('Recalculation with Async Templates (T661335)', () => { + beforeEach(() => { + fx.off = true; + setupSchedulerTestEnvironment({ height: 600 }); + }); + + afterEach(() => { + const $scheduler = $(document.querySelector(`.${CLASSES.scheduler}`)); + // @ts-expect-error + $scheduler.dxScheduler('dispose'); + document.body.innerHTML = ''; + fx.off = false; + }); + + it('should not duplicate workspace elements when resources are loaded asynchronously (T661335)', async () => { + const { scheduler, container } = await createScheduler({ + templatesRenderAsynchronously: true, + currentView: 'day', + views: ['day'], + groups: ['owner'], + resources: [ + { + fieldExpr: 'owner', + dataSource: [{ id: 1, text: 'Owner 1' }], + }, + { + fieldExpr: 'room', + dataSource: new CustomStore({ + load(): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve([{ id: 1, text: 'Room 1', color: '#ff0000' }]); + }); + }); + }, + }), + }, + ], + dataSource: [ + { + text: 'Meeting in Room 1', + startDate: new Date(2017, 4, 25, 9, 0), + endDate: new Date(2017, 4, 25, 10, 0), + roomId: 1, + }, + ], + startDayHour: 9, + currentDate: new Date(2017, 4, 25), + height: 600, + }); + + scheduler.option('groups', ['room']); + + await new Promise((r) => { setTimeout(r); }); + + const $workSpaces = $(container).find(`.${CLASSES.workSpace}`); + const $groupHeader = $(container).find('.dx-scheduler-group-header'); + + expect($workSpaces.length).toBe(1); + + expect($groupHeader.length).toBeGreaterThan(0); + expect($groupHeader.text()).toContain('Room 1'); + }); + }); + + describe('scrollTo', () => { + beforeEach(() => { + fx.off = true; + setupSchedulerTestEnvironment({ height: 600 }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + fx.off = false; + }); + + it('T1310544: should scroll to date with offset: 720 (12 hours)', async () => { + const { scheduler } = await createScheduler({ + views: ['timelineDay'], + currentView: 'timelineDay', + currentDate: new Date(2021, 1, 2), + firstDayOfWeek: 0, + startDayHour: 6, + endDayHour: 18, + offset: 720, + cellDuration: 60, + height: 580, + }); + + const workspace = scheduler.getWorkSpace(); + const scrollable = workspace.getScrollable(); + const $scrollable = scrollable.$element(); + const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); + + // With offset: 720 (12 hours), cells start at 18:00 (6:00 + 12h) + // For date 22:00, this should be cell index 4 (18:00=0, 19:00=1, 20:00=2, 21:00=3, 22:00=4) + const leftCellCount = 4; + const cellWidth = workspace.getCellWidth(); + const scrollableWidth = getWidth($scrollable); + const expectedLeft = leftCellCount * cellWidth - (scrollableWidth - cellWidth) / 2; + + const targetDate = new Date(2021, 1, 2, 22, 0); + scheduler.scrollTo(targetDate, undefined, false); + + expect(scrollBySpy).toHaveBeenCalledTimes(1); + const scrollParams = scrollBySpy.mock.calls[0][0] as { left: number; top: number }; + expect(scrollParams.left).toBeCloseTo(expectedLeft, 1); + + scrollBySpy.mockRestore(); + }); + + it('should scroll to date with offset after midnight: 720 (12 hours)', async () => { + const { scheduler } = await createScheduler({ + views: ['timelineDay'], + currentView: 'timelineDay', + currentDate: new Date(2021, 1, 2), + firstDayOfWeek: 0, + startDayHour: 6, + endDayHour: 18, + offset: 720, + cellDuration: 60, + height: 580, + }); + + const workspace = scheduler.getWorkSpace(); + const scrollable = workspace.getScrollable(); + const $scrollable = scrollable.$element(); + const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); + + // With offset: 720 (12 hours), cells start at 18:00 (6:00 + 12h) + // For date 3 feb 04:00, this should be cell index 10 (18:00=0, 19:00=1, ... 04:00=10) + const leftCellCount = 10; + const cellWidth = workspace.getCellWidth(); + const scrollableWidth = getWidth($scrollable); + const expectedLeft = leftCellCount * cellWidth - (scrollableWidth - cellWidth) / 2; + + const targetDate = new Date(2021, 1, 3, 4, 0); + scheduler.scrollTo(targetDate, undefined, false); + + expect(scrollBySpy).toHaveBeenCalledTimes(1); + const scrollParams = scrollBySpy.mock.calls[0][0] as { left: number; top: number }; + expect(scrollParams.left).toBeCloseTo(expectedLeft, 1); + + scrollBySpy.mockRestore(); + }); + + it('should scroll to end of day', async () => { + const { scheduler } = await createScheduler({ + views: ['timelineWeek'], + currentView: 'timelineWeek', + currentDate: new Date(2021, 1, 2), + firstDayOfWeek: 0, + startDayHour: 6, + endDayHour: 18, + offset: 120, + cellDuration: 60, + height: 580, + }); + + const workspace = scheduler.getWorkSpace(); + const scrollable = workspace.getScrollable(); + const $scrollable = scrollable.$element(); + const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); + + // With offset: 720 (12 hours), cells start at 18:00 (6:00 + 12h) + // For date 3 feb 04:00, this should be cell index 3 (18:00=0, ... 22:00=35) + const leftCellCount = 35; + const cellWidth = workspace.getCellWidth(); + const scrollableWidth = getWidth($scrollable); + const expectedLeft = leftCellCount * cellWidth - (scrollableWidth - cellWidth) / 2; + + const targetDate = new Date(2021, 1, 2, 22, 0); + scheduler.scrollTo(targetDate, undefined, false); + + expect(scrollBySpy).toHaveBeenCalledTimes(1); + const scrollParams = scrollBySpy.mock.calls[0][0] as { left: number; top: number }; + expect(scrollParams.left).toBeCloseTo(expectedLeft, 1); + + scrollBySpy.mockRestore(); + }); + + describe('hour normalization', () => { + it('should normalize hours to visible range without viewOffset', async () => { + const { scheduler } = await createScheduler({ + views: ['timelineDay'], + currentView: 'timelineDay', + currentDate: new Date(2021, 1, 2), + startDayHour: 6, + endDayHour: 18, + offset: 0, + }); + + const workspace = scheduler.getWorkSpace(); + const scrollable = workspace.getScrollable(); + const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); + + // Below startDayHour (6), should NOT normalize to 6 (?) + const dateBelowRange = new Date(2021, 1, 2, 4, 0); + scheduler.scrollTo(dateBelowRange, undefined, false); + expect(scrollBySpy).not.toHaveBeenCalled(); + + scrollBySpy.mockClear(); + // Above endDayHour (18), should NOT normalize to 17 (?) + const dateAboveRange = new Date(2021, 1, 2, 20, 0); + scheduler.scrollTo(dateAboveRange, undefined, false); + expect(scrollBySpy).not.toHaveBeenCalled(); + + scrollBySpy.mockClear(); + // Within range [6, 18), should scroll normally + const dateInRange = new Date(2021, 1, 2, 12, 0); + scheduler.scrollTo(dateInRange, undefined, false); + expect(scrollBySpy).toHaveBeenCalled(); + + scrollBySpy.mockRestore(); + }); + + it('should normalize hours to visible range with viewOffset (no midnight crossing)', async () => { + const { scheduler } = await createScheduler({ + views: ['timelineDay'], + currentView: 'timelineDay', + currentDate: new Date(2021, 1, 2), + startDayHour: 6, + endDayHour: 18, + offset: 360, + }); + + const workspace = scheduler.getWorkSpace(); + const scrollable = workspace.getScrollable(); + const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); + + // Below adjustedStartDayHour (12), should NOT normalize to 12 (?) + const dateBelowAdjustedRange = new Date(2021, 1, 2, 10, 0); + scheduler.scrollTo(dateBelowAdjustedRange, undefined, false); + expect(scrollBySpy).not.toHaveBeenCalled(); + + scrollBySpy.mockClear(); + // Within adjusted range [12, 24), should scroll normally + const dateInAdjustedRange = new Date(2021, 1, 2, 15, 0); + scheduler.scrollTo(dateInAdjustedRange, undefined, false); + expect(scrollBySpy).toHaveBeenCalled(); + + scrollBySpy.mockRestore(); + }); + + it('should normalize hours to visible range with viewOffset (midnight crossing)', async () => { + const { scheduler } = await createScheduler({ + views: ['timelineDay'], + currentView: 'timelineDay', + currentDate: new Date(2021, 1, 2), + startDayHour: 6, + endDayHour: 18, + offset: 720, + }); + + const workspace = scheduler.getWorkSpace(); + const scrollable = workspace.getScrollable(); + const scrollBySpy = jest.spyOn(scrollable, 'scrollBy'); + + // In gap [6, 18), should NOT normalize to 18:00 Feb 2 (?) + const dateInGap = new Date(2021, 1, 2, 10, 0); + scheduler.scrollTo(dateInGap, undefined, false); + expect(scrollBySpy).not.toHaveBeenCalled(); + + scrollBySpy.mockClear(); + // In range [18, 24) on Feb 2, should scroll normally + const dateInFirstRange = new Date(2021, 1, 2, 22, 0); + scheduler.scrollTo(dateInFirstRange, undefined, false); + expect(scrollBySpy).toHaveBeenCalled(); + + scrollBySpy.mockClear(); + // In range [0, 6) but on wrong day (Feb 2), should NOT normalize to 18:00 Feb 2 (?) + const dateInSecondRangeWrongDay = new Date(2021, 1, 2, 3, 0); + scheduler.scrollTo(dateInSecondRangeWrongDay, undefined, false); + expect(scrollBySpy).not.toHaveBeenCalled(); + + scrollBySpy.mockClear(); + // In range [0, 6) on correct day (Feb 3), should scroll normally + const dateInSecondRangeCorrectDay = new Date(2021, 1, 3, 3, 0); + scheduler.scrollTo(dateInSecondRangeCorrectDay, undefined, false); + expect(scrollBySpy).toHaveBeenCalled(); + + scrollBySpy.mockRestore(); + }); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index c1649f57b664..c02df2f016bc 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -691,8 +691,8 @@ class SchedulerWorkSpace extends Widget { } if (this.isVirtualScrolling() - && (this.virtualScrollingDispatcher.horizontalScrollingAllowed - || this.virtualScrollingDispatcher.height)) { + && (this.virtualScrollingDispatcher.horizontalScrollingAllowed + || this.virtualScrollingDispatcher.height)) { const currentOnScroll = config.onScroll; config = { ...config, @@ -1407,19 +1407,33 @@ class SchedulerWorkSpace extends Widget { return (this.$element() as any).find(`.${GROUP_HEADER_CLASS}`); } - _getScrollCoordinates(hours, minutes, date, groupIndex?: any, allDay?: any) { - const currentDate = date || new Date(this.option('currentDate')); + _normalizeHoursToVisibleRange(hours: number): number { const startDayHour = this.option('startDayHour'); const endDayHour = this.option('endDayHour'); + const viewOffset = this.option('viewOffset') as number; + const viewOffsetHours = viewOffset / HOUR_MS; - if (hours < startDayHour) { - hours = startDayHour; - } + const adjustedStartDayHour = (startDayHour + viewOffsetHours) % 24; + const adjustedEndDayHour = (endDayHour + viewOffsetHours) % 24; + const crossesMidnight = adjustedEndDayHour > 0 && adjustedStartDayHour >= adjustedEndDayHour; + const effectiveEndDayHour = adjustedEndDayHour === 0 ? 24 : adjustedEndDayHour; - if (hours >= endDayHour) { - hours = endDayHour - 1; + if (!crossesMidnight) { + if (hours < adjustedStartDayHour) { + return adjustedStartDayHour; + } + if (hours >= effectiveEndDayHour) { + return effectiveEndDayHour - 1; + } } + return hours; + } + + _getScrollCoordinates(hours, minutes, date, groupIndex?: any, allDay?: any) { + const currentDate = date || new Date(this.option('currentDate')); + hours = this._normalizeHoursToVisibleRange(hours); + currentDate.setHours(hours, minutes, 0, 0); const cell = this.viewDataProvider.findGlobalCellPosition(currentDate, groupIndex, allDay); @@ -1511,8 +1525,8 @@ class SchedulerWorkSpace extends Widget { isGroupedByDate() { return this.option('groupByDate') - && this._isHorizontalGroupedWorkSpace() - && this._getGroupCount() > 0; + && this._isHorizontalGroupedWorkSpace() + && this._getGroupCount() > 0; } // TODO: refactor current time indicator @@ -1770,9 +1784,9 @@ class SchedulerWorkSpace extends Widget { const cellEndTime = cellEndDate.getTime(); if (((!inAllDayRow && cellStartTime <= time - && time < cellEndTime) - || (inAllDayRow && trimmedTime === cellStartTime)) - && groupIndex === cellGroupIndex) { + && time < cellEndTime) + || (inAllDayRow && trimmedTime === cellStartTime)) + && groupIndex === cellGroupIndex) { return false; } return currentResult; @@ -1813,9 +1827,9 @@ class SchedulerWorkSpace extends Widget { const rowIndex = index / totalColumnCount; if (scrolledColumnCount <= columnIndex - && columnIndex < columnCount - && scrolledRowCount <= rowIndex - && rowIndex < rowCount) { + && columnIndex < columnCount + && scrolledRowCount <= rowIndex + && rowIndex < rowCount) { result.push($cell); } }); @@ -1867,8 +1881,11 @@ class SchedulerWorkSpace extends Widget { _isValidScrollDate(date, throwWarning = true) { const min = this.getStartViewDate(); const max = this.getEndViewDate(); + const viewOffset = this.option('viewOffset') as number; + const adjustedMin = new Date(min.getTime() + viewOffset); + const adjustedMax = new Date(max.getTime() + viewOffset); - if (date < min || date > max) { + if (date < adjustedMin || date > adjustedMax) { throwWarning && errors.log('W1008', date); return false; } @@ -3360,10 +3377,10 @@ const createDragBehaviorConfig = ( const isCurrentSchedulerElement = dateTables.find(el).length === 1; return isCurrentSchedulerElement - && ( - classList.contains(DATE_TABLE_CELL_CLASS) - || classList.contains(ALL_DAY_TABLE_CELL_CLASS) - ); + && ( + classList.contains(DATE_TABLE_CELL_CLASS) + || classList.contains(ALL_DAY_TABLE_CELL_CLASS) + ); }); if (droppableCell) {