From 67cd4825037aa6d6f43fcb5e73cb97a4980db327 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Wed, 14 Jan 2026 05:35:55 -0300 Subject: [PATCH 1/3] Scheduler: add new scrollTo API --- .../__tests__/workspace.base.test.ts | 133 +++++++++++++++++- .../js/__internal/scheduler/m_scheduler.ts | 35 ++++- .../scheduler/workspaces/m_work_space.ts | 6 +- packages/devextreme/js/ui/scheduler.d.ts | 14 ++ packages/devextreme/ts/dx.all.d.ts | 11 ++ 5 files changed, 189 insertions(+), 10 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts index 297f343f1769..886ace2cc3f2 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts @@ -1,6 +1,7 @@ import { - describe, expect, it, jest, + beforeAll, beforeEach, describe, expect, it, jest, } from '@jest/globals'; +import { logger } from '@ts/core/utils/m_console'; import { getResourceManagerMock } from '../__mock__/resource_manager.mock'; import SchedulerTimelineDay from '../workspaces/m_timeline_day'; @@ -12,13 +13,38 @@ import SchedulerWorkSpaceDay from '../workspaces/m_work_space_day'; import SchedulerWorkSpaceMonth from '../workspaces/m_work_space_month'; import SchedulerWorkSpaceWeek from '../workspaces/m_work_space_week'; import SchedulerWorkSpaceWorkWeek from '../workspaces/m_work_space_work_week'; +import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; + +jest.mock('@ts/core/m_devices', () => { + const originalModule: any = jest.requireActual('@ts/core/m_devices'); + const real = jest.fn().mockReturnValue({ + platform: 'mac', + mac: true, + deviceType: 'desktop', + }); + const current = jest.fn().mockReturnValue({ + platform: 'mac', + mac: true, + deviceType: 'desktop', + }); + + return { + __esModule: true, + default: { + ...originalModule.default, + isSimulator: originalModule.default.isSimulator, + real, + current, + }, + }; +}); type WorkspaceConstructor = new (container: Element, options?: any) => T; const createWorkspace = ( WorkSpace: WorkspaceConstructor, currentView: string, -): T => { +): { workspace: T; container: Element } => { const container = document.createElement('div'); const workspace = new WorkSpace(container, { views: [currentView], @@ -30,8 +56,9 @@ const createWorkspace = ( (workspace as any)._isVisible = () => true; expect(container.classList).toContain('dx-scheduler-work-space'); - return workspace; + return { workspace, container }; }; + const workSpaces: { currentView: string; WorkSpace: WorkspaceConstructor; @@ -46,10 +73,14 @@ const workSpaces: { { currentView: 'timelineMonth', WorkSpace: SchedulerTimelineMonth }, ]; +beforeAll(() => { + setupSchedulerTestEnvironment(); +}); + describe('scheduler workspace', () => { workSpaces.forEach(({ currentView, WorkSpace }) => { it(`should clear cache on dimension change, view: ${currentView}`, () => { - const workspace = createWorkspace(WorkSpace, currentView); + const { workspace } = createWorkspace(WorkSpace, currentView); jest.spyOn(workspace.cache, 'clear'); workspace.cache.memo('test', () => 'value'); @@ -59,7 +90,7 @@ describe('scheduler workspace', () => { }); it(`should clear cache on _cleanView call, view: ${currentView}`, () => { - const workspace = createWorkspace(WorkSpace, currentView); + const { workspace } = createWorkspace(WorkSpace, currentView); jest.spyOn(workspace.cache, 'clear'); workspace.cache.memo('test', () => 'value'); @@ -70,3 +101,95 @@ describe('scheduler workspace', () => { }); }); }); + +describe('scheduler workspace scrollTo', () => { + beforeEach(() => { + setupSchedulerTestEnvironment(); + }); + + it('should change scroll position with center alignment', () => { + const { workspace, container } = createWorkspace(SchedulerTimelineDay, 'timelineDay'); + + const scrollableElement = container.querySelector('.dx-scheduler-date-table-scrollable') as HTMLElement; + const scrollableContainer = scrollableElement.querySelector('.dx-scrollable-container') as HTMLElement; + + workspace.scrollTo(new Date(2017, 4, 25, 22, 0)); + + expect(scrollableContainer.scrollLeft).toBeCloseTo(11125); + }); + + it('should not change scroll position when date is outside view range', () => { + const { workspace, container } = createWorkspace(SchedulerTimelineDay, 'timelineDay'); + + const scrollableElement = container.querySelector('.dx-scheduler-date-table-scrollable') as HTMLElement; + const scrollableContainer = scrollableElement.querySelector('.dx-scrollable-container') as HTMLElement; + + workspace.scrollTo(new Date(2030, 0, 1)); + + expect(scrollableContainer.scrollLeft).toBeCloseTo(0); + expect(scrollableContainer.scrollTop).toBeCloseTo(0); + }); + + it('should scroll with start alignment', () => { + const { workspace, container } = createWorkspace(SchedulerTimelineDay, 'timelineDay'); + + const scrollableElement = container.querySelector('.dx-scheduler-date-table-scrollable') as HTMLElement; + const scrollableContainer = scrollableElement.querySelector('.dx-scrollable-container') as HTMLElement; + + workspace.scrollTo(new Date(2017, 4, 25, 22, 0), undefined, false, true, 'start'); + + expect(scrollableContainer.scrollLeft).toBeCloseTo(11000); + expect(scrollableContainer.scrollTop).toBeCloseTo(0); + }); + + it('should scroll with center alignment', () => { + const { workspace, container } = createWorkspace(SchedulerTimelineDay, 'timelineDay'); + + const scrollableElement = container.querySelector('.dx-scheduler-date-table-scrollable') as HTMLElement; + const scrollableContainer = scrollableElement.querySelector('.dx-scrollable-container') as HTMLElement; + + workspace.scrollTo(new Date(2017, 4, 25, 22, 0), undefined, false, true, 'center'); + + expect(scrollableContainer.scrollLeft).toBeCloseTo(11125); + }); + + it('should scroll to all day panel when allDay is true', () => { + const { workspace, container } = createWorkspace(SchedulerTimelineDay, 'timelineDay'); + + const scrollableElement = container.querySelector('.dx-scheduler-date-table-scrollable') as HTMLElement; + const scrollableContainer = scrollableElement.querySelector('.dx-scrollable-container') as HTMLElement; + + workspace.scrollTo(new Date(2017, 4, 25, 22, 0), undefined, true); + + expect(scrollableContainer.scrollLeft).toBeCloseTo(11125); + }); + + it('should handle throwWarning parameter correctly', () => { + const loggerWarnSpy = jest.spyOn(logger, 'warn'); + loggerWarnSpy.mockReset(); + + const { workspace, container } = createWorkspace(SchedulerTimelineDay, 'timelineDay'); + + const scrollableElement = container.querySelector('.dx-scheduler-date-table-scrollable') as HTMLElement; + const scrollableContainer = scrollableElement.querySelector('.dx-scrollable-container') as HTMLElement; + + workspace.scrollTo(new Date(2030, 0, 1), undefined, false, true); + + expect(scrollableContainer.scrollLeft).toBe(0); + expect(scrollableContainer.scrollTop).toBe(0); + expect(loggerWarnSpy).toHaveBeenCalledTimes(1); + expect(loggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining('W1008')); + }); + + it('should apply RTL offset when rtlEnabled is true', () => { + const { workspace, container } = createWorkspace(SchedulerTimelineDay, 'timelineDay'); + workspace.option('rtlEnabled', true); + + const scrollableElement = container.querySelector('.dx-scheduler-date-table-scrollable') as HTMLElement; + const scrollableContainer = scrollableElement.querySelector('.dx-scrollable-container') as HTMLElement; + + workspace.scrollTo(new Date(2017, 4, 25, 22, 0)); + + expect(scrollableContainer.scrollLeft).toBeCloseTo(-11125); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index d4bba916e5ae..395e3a855ab5 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -77,6 +77,7 @@ import type { NormalizedView } from './utils/options/types'; import { setAppointmentGroupValues } from './utils/resource_manager/appointment_groups_utils'; import { createResourceEditorModel } from './utils/resource_manager/popup_utils'; import { ResourceManager } from './utils/resource_manager/resource_manager'; +import type { GroupValues, RawGroupValues } from './utils/resource_manager/types'; import AppointmentLayoutManager from './view_model/appointments_layout_manager'; import { AppointmentDataSource } from './view_model/m_appointment_data_source'; import type { AppointmentViewModelPlain } from './view_model/types'; @@ -90,6 +91,14 @@ import SchedulerWorkSpaceMonth from './workspaces/m_work_space_month'; import SchedulerWorkSpaceWeek from './workspaces/m_work_space_week'; import SchedulerWorkSpaceWorkWeek from './workspaces/m_work_space_work_week'; +interface ScrollToOptions { + group?: RawGroupValues | GroupValues; + allDay?: boolean | undefined; + align?: 'start' | 'center'; +} + +type ScrollToGroupValuesOrOptions = RawGroupValues | GroupValues | ScrollToOptions | undefined; + const toMs = dateUtils.dateToMilliseconds; const WIDGET_CLASS = 'dx-scheduler'; @@ -2022,8 +2031,30 @@ class Scheduler extends SchedulerOptionsBaseWidget { this._appointmentTooltip?.hide(); } - scrollTo(date, groupValues, allDay) { - this._workSpace.scrollTo(date, groupValues, allDay); + scrollTo( + date: Date, + groupValuesOrOptions?: ScrollToGroupValuesOrOptions, + allDay?: boolean | undefined, + ) { + let groupValues; + let allDayValue; + let align: 'start' | 'center' = 'center'; + + if (this._isScrollOptionsObject(groupValuesOrOptions)) { + groupValues = groupValuesOrOptions.group; + allDayValue = groupValuesOrOptions.allDay; + align = groupValuesOrOptions.align ?? 'center'; + } else { + groupValues = groupValuesOrOptions; + allDayValue = allDay; + } + + this._workSpace.scrollTo(date, groupValues, allDayValue, true, align); + } + + private _isScrollOptionsObject(options?: ScrollToGroupValuesOrOptions): options is ScrollToOptions { + return Boolean(options) && typeof options === 'object' + && ('align' in options || 'allDay' in options || 'group' in options); } _isHorizontalVirtualScrolling() { 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..38cd9973dad2 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -1823,7 +1823,7 @@ class SchedulerWorkSpace extends Widget { return result; } - scrollTo(date, groupValues?: RawGroupValues | GroupValues, allDay = false, throwWarning = true) { + scrollTo(date: Date, groupValues?: RawGroupValues | GroupValues, allDay = false, throwWarning = true, align: 'start' | 'center' = 'center') { if (!this._isValidScrollDate(date, throwWarning)) { return; } @@ -1846,8 +1846,8 @@ class SchedulerWorkSpace extends Widget { const scrollableWidth = getWidth($scrollable); const cellHeight = this.getCellHeight(); - const xShift = (scrollableWidth - cellWidth) / 2; - const yShift = (scrollableHeight - cellHeight) / 2; + const xShift = align === 'start' ? 0 : (scrollableWidth - cellWidth) / 2; + const yShift = align === 'start' ? 0 : (scrollableHeight - cellHeight) / 2; const left = coordinates.left - scrollable.scrollLeft() - xShift - offset; let top = coordinates.top - scrollable.scrollTop() - yShift; diff --git a/packages/devextreme/js/ui/scheduler.d.ts b/packages/devextreme/js/ui/scheduler.d.ts index 4920cd3953ed..57c42890773a 100644 --- a/packages/devextreme/js/ui/scheduler.d.ts +++ b/packages/devextreme/js/ui/scheduler.d.ts @@ -1308,6 +1308,20 @@ export default class dxScheduler extends Widget { * @public */ scrollTo(date: Date, group?: object, allDay?: boolean): void; + /** + * @docid + * @publicName scrollTo(date, options) + * @param2 options:Object|undefined + * @param2_field group:Object|undefined + * @param2_field allDay:Boolean|undefined + * @param2_field align:Enums.SchedulerScrollToAlign|undefined + * @public + */ + scrollTo(date: Date, options?: { + group?: object; + allDay?: boolean; + align?: 'start' | 'center'; + }): void; /** * @docid * @publicName showAppointmentPopup(appointmentData, createNewAppointment, currentAppointmentData) diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index a42643d2366b..a4f7e547261b 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -25849,6 +25849,17 @@ declare module DevExpress.ui { * [descr:dxScheduler.scrollTo(date, group, allDay)] */ scrollTo(date: Date, group?: object, allDay?: boolean): void; + /** + * [descr:dxScheduler.scrollTo(date, options)] + */ + scrollTo( + date: Date, + options?: { + group?: object; + allDay?: boolean; + align?: 'start' | 'center'; + } + ): void; /** * [descr:dxScheduler.showAppointmentPopup(appointmentData, createNewAppointment, currentAppointmentData)] */ From 7d6be021716ffb3e9f431ada2ea28b7b7542bb3a Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Thu, 15 Jan 2026 08:47:41 -0300 Subject: [PATCH 2/3] Fix after review: implement deprecation warning for old scrollTo API --- .../scheduler/__tests__/scheduler.test.ts | 37 +++++++++++++++++++ .../js/__internal/scheduler/m_scheduler.ts | 12 +----- .../js/__internal/scheduler/types.ts | 13 +++++++ 3 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 packages/devextreme/js/__internal/scheduler/__tests__/scheduler.test.ts diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/scheduler.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/scheduler.test.ts new file mode 100644 index 000000000000..0f167cf6fc41 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/scheduler.test.ts @@ -0,0 +1,37 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; +import { logger } from '@ts/core/utils/m_console'; + +import { createScheduler } from './__mock__/create_scheduler'; +import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; + +describe('Scheduler scrollTo deprecation', () => { + it('should log deprecation warning when using old scrollTo API', async () => { + setupSchedulerTestEnvironment(); + const loggerWarnSpy = jest.spyOn(logger, 'warn'); + + const { scheduler } = await createScheduler({ + dataSource: [{ + text: 'Meeting', + startDate: new Date(2025, 0, 15, 9, 0), + endDate: new Date(2025, 0, 15, 10, 0), + }], + views: ['week'], + currentView: 'week', + currentDate: new Date(2025, 0, 15), + startDayHour: 8, + endDayHour: 18, + }); + loggerWarnSpy.mockReset(); + + const testDate = new Date(2025, 0, 16, 14, 0); + + scheduler.scrollTo(testDate, undefined, false); + + expect(loggerWarnSpy).toHaveBeenCalledTimes(1); + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('W0002'), + ); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index 395e3a855ab5..53fbf7df38ec 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -64,7 +64,7 @@ import { MobileTooltipStrategy } from './tooltip_strategies/m_mobile_tooltip_str import type { AppointmentTooltipItem, SafeAppointment, - TargetedAppointment, + ScrollToGroupValuesOrOptions, ScrollToOptions, TargetedAppointment, } from './types'; import { AppointmentAdapter } from './utils/appointment_adapter/appointment_adapter'; import { AppointmentDataAccessor } from './utils/data_accessor/appointment_data_accessor'; @@ -77,7 +77,6 @@ import type { NormalizedView } from './utils/options/types'; import { setAppointmentGroupValues } from './utils/resource_manager/appointment_groups_utils'; import { createResourceEditorModel } from './utils/resource_manager/popup_utils'; import { ResourceManager } from './utils/resource_manager/resource_manager'; -import type { GroupValues, RawGroupValues } from './utils/resource_manager/types'; import AppointmentLayoutManager from './view_model/appointments_layout_manager'; import { AppointmentDataSource } from './view_model/m_appointment_data_source'; import type { AppointmentViewModelPlain } from './view_model/types'; @@ -91,14 +90,6 @@ import SchedulerWorkSpaceMonth from './workspaces/m_work_space_month'; import SchedulerWorkSpaceWeek from './workspaces/m_work_space_week'; import SchedulerWorkSpaceWorkWeek from './workspaces/m_work_space_work_week'; -interface ScrollToOptions { - group?: RawGroupValues | GroupValues; - allDay?: boolean | undefined; - align?: 'start' | 'center'; -} - -type ScrollToGroupValuesOrOptions = RawGroupValues | GroupValues | ScrollToOptions | undefined; - const toMs = dateUtils.dateToMilliseconds; const WIDGET_CLASS = 'dx-scheduler'; @@ -2045,6 +2036,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { allDayValue = groupValuesOrOptions.allDay; align = groupValuesOrOptions.align ?? 'center'; } else { + errors.log('W0002', 'dxScheduler', 'scrollTo', '26.1', 'Use an object with "group", "allDay", and "align" properties instead of separate parameters.'); groupValues = groupValuesOrOptions; allDayValue = allDay; } diff --git a/packages/devextreme/js/__internal/scheduler/types.ts b/packages/devextreme/js/__internal/scheduler/types.ts index 5326f622e1d6..78c7f3befd45 100644 --- a/packages/devextreme/js/__internal/scheduler/types.ts +++ b/packages/devextreme/js/__internal/scheduler/types.ts @@ -2,6 +2,7 @@ import type { dxElementWrapper } from '@js/core/renderer'; import type { Appointment, Properties } from '@js/ui/scheduler'; import type { ResourceLoader } from './utils/loader/resource_loader'; +import type { GroupValues, RawGroupValues } from './utils/resource_manager/types'; import type { AppointmentViewModelPlain } from './view_model/types'; export type Direction = 'vertical' | 'horizontal'; @@ -269,3 +270,15 @@ export interface CompactAppointmentOptions { allowDrag: boolean; isCompact: boolean; } + +export interface ScrollToOptions { + group?: RawGroupValues | GroupValues; + allDay?: boolean | undefined; + align?: 'start' | 'center'; +} + +export type ScrollToGroupValuesOrOptions = + | RawGroupValues + | GroupValues + | ScrollToOptions + | undefined; From 5edc542c4ba31d815572b10ef631f014b5a33d41 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Thu, 15 Jan 2026 09:14:45 -0300 Subject: [PATCH 3/3] fix: verify deprecation warning for old API --- .../scrollTo.tests.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/scrollTo.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/scrollTo.tests.js index 8837b5a08658..7fbb5e40957b 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/scrollTo.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/scrollTo.tests.js @@ -106,14 +106,16 @@ module('ScrollTo', { scheduler.instance.scrollTo(new Date(2020, 8, 5)); await waitAsync(0); - assert.equal(errors.log.callCount, 1, 'warning has been called once'); - assert.equal(errors.log.getCall(0).args[0], 'W1008', 'warning has correct error id'); + assert.equal(errors.log.callCount, 2, 'warnings have been called twice'); + assert.equal(errors.log.getCall(0).args[0], 'W0002', 'first warning is deprecation warning'); + assert.equal(errors.log.getCall(1).args[0], 'W1008', 'second warning has correct error id'); scheduler.instance.scrollTo(new Date(2020, 8, 14)); await waitAsync(0); - assert.equal(errors.log.callCount, 2, 'warning has been called once'); - assert.equal(errors.log.getCall(1).args[0], 'W1008', 'warning has correct error id'); + assert.equal(errors.log.callCount, 4, 'warnings have been called four times total'); + assert.equal(errors.log.getCall(2).args[0], 'W0002', 'third warning is deprecation warning'); + assert.equal(errors.log.getCall(3).args[0], 'W1008', 'fourth warning has correct error id'); }); test(`A warning should not be thrown when scrolling to a valid date when ${scrolling.text} is used`, async function(assert) { @@ -122,7 +124,8 @@ module('ScrollTo', { scheduler.instance.scrollTo(new Date(2020, 8, 7)); await waitAsync(0); - assert.equal(errors.log.callCount, 0, 'warning has been called once'); + assert.equal(errors.log.callCount, 1, 'deprecation warning has been called once'); + assert.equal(errors.log.getCall(0).args[0], 'W0002', 'warning is deprecation warning for old API'); }); [{