diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/__tests__/data_controller.paging.test.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/__tests__/data_controller.paging.test.ts new file mode 100644 index 000000000000..055a9d9e8ed4 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/__tests__/data_controller.paging.test.ts @@ -0,0 +1,258 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; + +import type { DataGridInstance } from '../../__tests__/__mock__/helpers/utils'; +import { + afterTest, + beforeTest, + createDataGrid, + flushAsync, +} from '../../__tests__/__mock__/helpers/utils'; + +const DATA = [ + { id: 1, value: 'a' }, + { id: 2, value: 'b' }, + { id: 3, value: 'c' }, + { id: 4, value: 'd' }, + { id: 5, value: 'e' }, +]; + +interface PagingAdapter { + paginate: () => boolean; + requireTotalCount: () => boolean; +} + +const getVisibleKeys = (instance: DataGridInstance): unknown[] => instance + .getVisibleRows() + .map((row) => row.key as unknown); + +const getIsPaging = (instance: DataGridInstance): unknown => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataController = instance.getController('data') as any; + + return dataController._isPaging as unknown; +}; + +// The controller's `dataSource()` is the DataSourceAdapter, not the raw DataSource. +const getAdapter = (instance: DataGridInstance): PagingAdapter => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataController = instance.getController('data') as any; + + return dataController.dataSource() as PagingAdapter; +}; + +describe('applyPagingOptions', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + describe('when no paging option actually changed (T677650)', () => { + it('should not reload and should not fire pageChanged', async () => { + const { instance } = await createDataGrid({ + dataSource: DATA, + keyExpr: 'id', + paging: { enabled: true, pageIndex: 0, pageSize: 3 }, + }); + + const pageChangedSpy = jest.fn(); + instance.getController('data').pageChanged.add(pageChangedSpy); + + const changedSpy = jest.fn(); + instance.getDataSource().on('changed', changedSpy); + + const loadingSpy = jest.fn(); + instance.getDataSource().store().on('loading', loadingSpy); + + instance.option('paging', { enabled: true, pageIndex: 0, pageSize: 3 }); + await flushAsync(); + + expect(pageChangedSpy).not.toHaveBeenCalled(); + expect(changedSpy).not.toHaveBeenCalled(); + expect(loadingSpy).not.toHaveBeenCalled(); + }); + + it('should apply requireTotalCount', async () => { + const { instance } = await createDataGrid({ + dataSource: DATA, + keyExpr: 'id', + paging: { enabled: true, pageIndex: 0, pageSize: 3 }, + }); + + const requireTotalCountSpy = jest.spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + instance.getDataSource() as any, + 'requireTotalCount', + ); + + instance.option('paging', { enabled: true, pageIndex: 0, pageSize: 3 }); + await flushAsync(); + + expect(requireTotalCountSpy).toHaveBeenCalledWith(true); + }); + }); + + // The paginate/pageIndex ordering itself is guarded in utils/__tests__/paging.test.ts. + describe('paging.enabled toggling', () => { + it('should keep paging.pageIndex but reset the dataSource when paging is disabled on a non-zero page', async () => { + const { instance } = await createDataGrid({ + dataSource: DATA, + keyExpr: 'id', + paging: { enabled: true, pageSize: 2 }, + }); + + instance.option('paging.pageIndex', 1); + await flushAsync(); + + expect(getVisibleKeys(instance)).toEqual([3, 4]); + + instance.option('paging.enabled', false); + await flushAsync(); + + expect({ + pagingPageIndex: instance.option('paging.pageIndex'), + dataSourcePageIndex: instance.getDataSource().pageIndex(), + visibleKeys: getVisibleKeys(instance), + }).toEqual({ + pagingPageIndex: 1, + dataSourcePageIndex: 0, + visibleKeys: [1, 2, 3, 4, 5], + }); + }); + + it('should land on the configured page when paging is enabled with a non-zero pageIndex', async () => { + const { instance } = await createDataGrid({ + dataSource: DATA, + keyExpr: 'id', + paging: { enabled: false, pageSize: 2, pageIndex: 1 }, + }); + + instance.option('paging.enabled', true); + await flushAsync(); + + expect(instance.option('paging.pageIndex')).toBe(1); + expect(instance.getDataSource().pageIndex()).toBe(1); + expect(getVisibleKeys(instance)).toEqual([3, 4]); + }); + }); + + describe('pageChanged and _isPaging on the option path', () => { + it('should fire pageChanged once with the new index when paging.pageIndex changes', async () => { + const { instance } = await createDataGrid({ + dataSource: DATA, + keyExpr: 'id', + paging: { enabled: true, pageSize: 2 }, + }); + + const pageChangedSpy = jest.fn(); + instance.getController('data').pageChanged.add(pageChangedSpy); + + instance.option('paging.pageIndex', 1); + await flushAsync(); + + expect(pageChangedSpy).toHaveBeenCalledTimes(1); + expect(pageChangedSpy).toHaveBeenCalledWith(1); + }); + + it('should fire pageChanged once when only paging.pageSize changes', async () => { + const { instance } = await createDataGrid({ + dataSource: DATA, + keyExpr: 'id', + paging: { enabled: true, pageSize: 2 }, + }); + + const pageChangedSpy = jest.fn(); + instance.getController('data').pageChanged.add(pageChangedSpy); + + instance.option('paging.pageSize', 3); + await flushAsync(); + + expect(pageChangedSpy).toHaveBeenCalledTimes(1); + expect(pageChangedSpy).toHaveBeenCalledWith(0); + }); + + it('should set _isPaging while the pageIndex load is in flight and clear it afterwards', async () => { + const { instance } = await createDataGrid({ + dataSource: DATA, + keyExpr: 'id', + paging: { enabled: true, pageSize: 2 }, + }); + + instance.option('paging.pageIndex', 1); + + expect(getIsPaging(instance)).toBe(true); + + await flushAsync(); + + expect(getIsPaging(instance)).toBe(false); + }); + + it('should set _isPaging to false, not undefined, when re-init changes no paging option', async () => { + const { instance } = await createDataGrid({ + dataSource: DATA, + keyExpr: 'id', + }); + + instance.option('dataSource', { + store: { type: 'array', key: 'id', data: [{ id: 9, value: 'z' }] }, + }); + + expect(getIsPaging(instance)).toBe(false); + + await flushAsync(); + }); + }); + + describe('option derivation', () => { + it('should disable requireTotalCount in infinite scrolling mode', async () => { + const { instance } = await createDataGrid({ + dataSource: DATA, + keyExpr: 'id', + height: 200, + paging: { pageSize: 2 }, + scrolling: { mode: 'infinite' }, + }); + + expect(getAdapter(instance).requireTotalCount()).toBe(false); + }); + + it('should enable requireTotalCount in regular paging mode', async () => { + const { instance } = await createDataGrid({ + dataSource: DATA, + keyExpr: 'id', + paging: { pageSize: 2 }, + }); + + expect(getAdapter(instance).requireTotalCount()).toBe(true); + expect(instance.option('paging.enabled')).toBe(true); + }); + + it('should paginate the dataSource in virtual mode even when paging is disabled', async () => { + const { instance } = await createDataGrid({ + dataSource: DATA, + keyExpr: 'id', + height: 200, + paging: { enabled: false, pageSize: 2 }, + scrolling: { mode: 'virtual' }, + }); + + expect(getAdapter(instance).paginate()).toBe(true); + expect(instance.option('paging.enabled')).toBe(false); + }); + }); + + describe('initial application', () => { + it('should push initial paging options down to the dataSource on first render', async () => { + const { instance } = await createDataGrid({ + dataSource: DATA, + keyExpr: 'id', + paging: { enabled: true, pageSize: 2, pageIndex: 1 }, + }); + + const dataSource = instance.getDataSource(); + + expect(dataSource.pageSize()).toBe(2); + expect(dataSource.pageIndex()).toBe(1); + expect(getVisibleKeys(instance)).toEqual([3, 4]); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts index 59ad5e97a6e4..9c78bc0d6452 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts @@ -48,6 +48,8 @@ import type { import gridCoreUtils from '../m_utils'; import type { VirtualScrollController } from '../virtual_scrolling/m_virtual_scrolling_core'; import { DataHelperMixin } from './data_helper_mixin'; +import type { PagingChanges, PagingDataSource } from './types'; +import { resolvePaginate, syncPaging } from './utils/paging'; const changePaging = function (that, optionName, value) { const dataSource = that._dataSource; @@ -343,18 +345,11 @@ export class DataController extends DataHelperMixin(modules.Controller) { } public optionChanged(args) { - const that = this; - let dataSource; - let changedPagingOptions; - - function handled() { - args.handled = true; - } - if (args.name === 'dataSource' - && args.name === args.fullName - && this._handleDataSourceChange(args)) { - handled(); + && args.name === args.fullName + && this._handleDataSourceChange(args) + ) { + args.handled = true; return; } @@ -363,43 +358,46 @@ export class DataController extends DataHelperMixin(modules.Controller) { case 'repaintChangesOnly': case 'highlightChanges': case 'loadingTimeout': - handled(); + args.handled = true; break; case 'remoteOperations': case 'keyExpr': case 'dataSource': case 'scrolling': - handled(); - that.reset(); + args.handled = true; + this.reset(); break; - case 'paging': - dataSource = that.dataSource(); + case 'paging': { + const dataSource = this.dataSource(); if (dataSource) { - changedPagingOptions = that._setPagingOptions(dataSource); - if (changedPagingOptions) { + const changedPagingOptions = this.applyPagingOptions(dataSource); + if (changedPagingOptions.hasChanges) { const pageIndex = dataSource.pageIndex(); this._isPaging = changedPagingOptions.isPageIndexChanged; dataSource.load().done(() => { this._isPaging = false; - that.pageChanged.fire(pageIndex); + this.pageChanged.fire(pageIndex); }); } } - handled(); + args.handled = true; break; + } case 'rtlEnabled': - that.reset(); + this.reset(); break; - case 'columns': - dataSource = that.dataSource(); + case 'columns': { + const dataSource = this.dataSource(); + if (dataSource && dataSource.isLoading() && args.name === args.fullName) { this._useSortingGroupingFromColumns = true; dataSource.load(); } break; + } default: super.optionChanged(args); } @@ -640,42 +638,18 @@ export class DataController extends DataHelperMixin(modules.Controller) { this.dataErrorOccurred.fire(errors.Error.apply(errors, args)); } - private _setPagingOptions(dataSource): any { - const pageIndex = this.option('paging.pageIndex'); - const pageSize = this.option('paging.pageSize'); - const pagingEnabled = this.option('paging.enabled'); - const scrollingMode = this.option('scrolling.mode'); - const appendMode = scrollingMode === 'infinite'; - const virtualMode = scrollingMode === 'virtual'; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const paginate = pagingEnabled || virtualMode || appendMode; - let isPaginateChanged = false; - let isPageSizeChanged = false; - let isPageIndexChanged = false; - - dataSource.requireTotalCount(!appendMode); - if (pagingEnabled !== undefined && dataSource.paginate() !== paginate) { - dataSource.paginate(paginate); - isPaginateChanged = true; - } - if (pageSize !== undefined && dataSource.pageSize() !== pageSize) { - dataSource.pageSize(pageSize); - isPageSizeChanged = true; - } - if (pageIndex !== undefined && dataSource.pageIndex() !== pageIndex) { - dataSource.pageIndex(pageIndex); - isPageIndexChanged = true; - } + private applyPagingOptions(dataSource: PagingDataSource): PagingChanges { + const { scrolling, paging } = this.option(); - if (isPaginateChanged || isPageSizeChanged || isPageIndexChanged) { - return { - isPaginateChanged, - isPageSizeChanged, - isPageIndexChanged, - }; - } + // Not paging state to reconcile, but a per-load request flag: infinite + // scrolling detects the last page locally and needs no grand total. + dataSource.requireTotalCount(scrolling?.mode !== 'infinite'); - return false; + return syncPaging(dataSource, { + paginate: resolvePaginate(paging?.enabled, scrolling?.mode), + pageSize: paging?.pageSize, + pageIndex: paging?.pageIndex, + }); } protected _getSpecificDataSourceOption() { @@ -695,20 +669,23 @@ export class DataController extends DataHelperMixin(modules.Controller) { } protected _initDataSource() { - const that = this; - const oldDataSource = this._dataSource; + const hadDataSource = !!this._dataSource; super._initDataSource(); - const dataSource = that._dataSource; - that._useSortingGroupingFromColumns = true; - that._cachedProcessedItems = null; + + // The raw DataSource for the new options, or null when there is no + // dataSource option. `setDataSource` below wraps it in the adapter. + const dataSource = this._dataSource; + this._useSortingGroupingFromColumns = true; + this._cachedProcessedItems = null; + if (dataSource) { - const changedPagingOptions = that._setPagingOptions(dataSource); + const { isPageIndexChanged } = this.applyPagingOptions(dataSource); - this._isPaging = changedPagingOptions?.isPageIndexChanged; - that.setDataSource(dataSource); - } else if (oldDataSource) { - that.updateItems(); + this._isPaging = isPageIndexChanged; + this.setDataSource(dataSource); + } else if (hadDataSource) { + this.updateItems(); } } diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/types.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/types.ts new file mode 100644 index 000000000000..8cbddafe481b --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/types.ts @@ -0,0 +1,24 @@ +export interface SyncPagingOptions { + paginate?: boolean; + pageSize?: number; + pageIndex?: number; +} + +export interface PagingChanges { + hasChanges: boolean; + isPaginateChanged: boolean; + isPageSizeChanged: boolean; + isPageIndexChanged: boolean; +} + +/** + * Either a raw DataSource or a DataSourceAdapter — the two are not + * interchangeable: the adapter's `pageSize()` returns 0 while paginate is off, + * and its `pageIndex()` is routed through virtual scrolling. + */ +export interface PagingDataSource { + paginate: (value?: boolean) => boolean | undefined; + pageSize: (value?: number) => number | undefined; + pageIndex: (value?: number) => number | undefined; + requireTotalCount: (value?: boolean) => boolean | undefined; +} diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/utils/__tests__/paging.test.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/utils/__tests__/paging.test.ts new file mode 100644 index 000000000000..185dc4ae86a5 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/utils/__tests__/paging.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from '@jest/globals'; + +import type { PagingDataSource } from '../../types'; +import { resolvePaginate, syncPaging } from '../paging'; + +describe('resolvePaginate', () => { + it.each([ + { enabled: true, scrollingMode: 'standard', expected: true }, + { enabled: false, scrollingMode: 'standard', expected: false }, + // Virtual and infinite scrolling paginate even with paging disabled. + { enabled: false, scrollingMode: 'virtual', expected: true }, + { enabled: false, scrollingMode: 'infinite', expected: true }, + { enabled: true, scrollingMode: 'virtual', expected: true }, + { enabled: true, scrollingMode: 'infinite', expected: true }, + ])('should be $expected for enabled=$enabled, scrolling.mode=$scrollingMode', ({ + enabled, scrollingMode, expected, + }) => { + expect(resolvePaginate(enabled, scrollingMode)).toBe(expected); + }); + + // An undefined `paging.enabled` leaves the data source's paginate alone, + // even in a mode that would otherwise force it on. + it.each(['standard', 'virtual', 'infinite'])( + 'should be undefined when enabled is undefined in %s mode', + (scrollingMode) => { + expect(resolvePaginate(undefined, scrollingMode)).toBeUndefined(); + }, + ); +}); + +const createDataSourceMock = ( + state: { paginate: boolean; pageSize: number; pageIndex: number }, +): PagingDataSource & { state: typeof state; calls: string[] } => { + const calls: string[] = []; + + return { + state, + calls, + paginate(value?: boolean): boolean | undefined { + if (value === undefined) { + return state.paginate; + } + + calls.push(`paginate(${value})`); + state.paginate = value; + // Mirrors DataSource.paginate, which resets pageIndex on change. + state.pageIndex = 0; + + return undefined; + }, + pageSize(value?: number): number | undefined { + // Mirrors the adapter, which reports 0 while paginate is off. + if (value === undefined) { + return state.paginate ? state.pageSize : 0; + } + + calls.push(`pageSize(${value})`); + state.pageSize = value; + + return undefined; + }, + pageIndex(value?: number): number | undefined { + if (value === undefined) { + return state.pageIndex; + } + + calls.push(`pageIndex(${value})`); + state.pageIndex = value; + + return undefined; + }, + requireTotalCount(value?: boolean): boolean | undefined { + if (value === undefined) { + return true; + } + + calls.push(`requireTotalCount(${value})`); + + return undefined; + }, + }; +}; + +describe('syncPaging', () => { + it('should report no changes and write nothing when the data source already matches', () => { + const dataSource = createDataSourceMock({ paginate: true, pageSize: 10, pageIndex: 2 }); + + const changes = syncPaging(dataSource, { + paginate: true, pageSize: 10, pageIndex: 2, + }); + + expect(changes).toEqual({ + hasChanges: false, + isPaginateChanged: false, + isPageSizeChanged: false, + isPageIndexChanged: false, + }); + expect(dataSource.calls).toEqual([]); + }); + + it('should skip members the target leaves undefined', () => { + const dataSource = createDataSourceMock({ paginate: true, pageSize: 10, pageIndex: 2 }); + + const changes = syncPaging(dataSource, { pageSize: 5 }); + + expect(changes.hasChanges).toBe(true); + expect(changes.isPageSizeChanged).toBe(true); + expect(changes.isPaginateChanged).toBe(false); + expect(changes.isPageIndexChanged).toBe(false); + expect(dataSource.state).toEqual({ paginate: true, pageSize: 5, pageIndex: 2 }); + }); + + it('should restore pageIndex after a paginate change resets it', () => { + const dataSource = createDataSourceMock({ paginate: false, pageSize: 10, pageIndex: 3 }); + + const changes = syncPaging(dataSource, { + paginate: true, pageSize: 10, pageIndex: 3, + }); + + expect(changes).toEqual({ + hasChanges: true, + isPaginateChanged: true, + isPageSizeChanged: false, + isPageIndexChanged: true, + }); + expect(dataSource.state.pageIndex).toBe(3); + expect(dataSource.calls).toEqual([ + 'paginate(true)', + 'pageIndex(3)', + ]); + }); + + it('should not report a pageSize change when only paginate turns on', () => { + const dataSource = createDataSourceMock({ paginate: false, pageSize: 10, pageIndex: 0 }); + + const changes = syncPaging(dataSource, { + paginate: true, pageSize: 10, pageIndex: 0, + }); + + expect(changes.isPaginateChanged).toBe(true); + expect(changes.isPageSizeChanged).toBe(false); + expect(dataSource.calls).toEqual(['paginate(true)']); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/utils/paging.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/utils/paging.ts new file mode 100644 index 000000000000..0d4ba2ec690a --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/utils/paging.ts @@ -0,0 +1,47 @@ +import type { PagingChanges, PagingDataSource, SyncPagingOptions } from '../types'; + +export function resolvePaginate( + enabled: boolean | undefined, + scrollingMode: string | undefined, +): boolean | undefined { + if (enabled === undefined) { + return undefined; + } + + return enabled || scrollingMode === 'virtual' || scrollingMode === 'infinite'; +} + +export function syncPaging( + dataSource: PagingDataSource, + options: SyncPagingOptions, +): PagingChanges { + const { paginate, pageIndex, pageSize } = options; + const isPaginateChanged = paginate !== undefined && dataSource.paginate() !== paginate; + + if (isPaginateChanged) { + dataSource.paginate(paginate); + } + + // Must be compared after dataSource.paginate: while paginate is off, + // the adapter's pageSize() reports 0 rather than the real page size. + const isPageSizeChanged = pageSize !== undefined && dataSource.pageSize() !== pageSize; + + if (isPageSizeChanged) { + dataSource.pageSize(pageSize); + } + + // Must be compared after dataSource.paginate: paginate() resets pageIndex to 0, + // and this comparison has to see the reset value so the target page gets restored. + const isPageIndexChanged = pageIndex !== undefined && dataSource.pageIndex() !== pageIndex; + + if (isPageIndexChanged) { + dataSource.pageIndex(pageIndex); + } + + return { + hasChanges: isPaginateChanged || isPageSizeChanged || isPageIndexChanged, + isPaginateChanged, + isPageSizeChanged, + isPageIndexChanged, + }; +} diff --git a/packages/devextreme/js/__internal/grids/grid_core/m_types.ts b/packages/devextreme/js/__internal/grids/grid_core/m_types.ts index b5c8996c3f0c..757634372bcd 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_types.ts @@ -1,11 +1,13 @@ /* eslint-disable spellcheck/spell-checker */ -import type { GridBase, GridBaseOptions, SelectionBase } from '@js/common/grids'; +import type { + GridBase, GridBaseOptions, SelectionBase, +} from '@js/common/grids'; import type { Component } from '@js/core/component'; import type { PropertyType } from '@js/core/index'; import type { dxElementWrapper } from '@js/core/renderer'; -import type { Properties as DataGridOptions } from '@js/ui/data_grid'; -import type { Properties as TreeListdOptions } from '@js/ui/tree_list'; +import type { Properties as DataGridOptions, Scrolling as DataGridScrolling } from '@js/ui/data_grid'; +import type { Properties as TreeListdOptions, Scrolling as TreeListScrolling } from '@js/ui/tree_list'; import type Widget from '@js/ui/widget/ui.widget'; import type { EditingController } from './editing/m_editing'; @@ -128,6 +130,8 @@ export interface InternalGridOptions extends GridBaseOptions { assert.deepEqual(dataGrid.getVisibleRows()[0].data, { value: 3 }, 'first row data'); }); - // T677650 - QUnit.test('paging change if nested options are not changed', function(assert) { - // arrange, act - const dataGrid = createDataGrid({ - loadingTimeout: null, - dataSource: { - store: [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }, { value: 5 }], - pageSize: 3 - } - }); - - const changedSpy = sinon.spy(); - const loadingSpy = sinon.spy(); - - dataGrid.getDataSource().on('changed', changedSpy); - dataGrid.getDataSource().store().on('loading', loadingSpy); - - // act - dataGrid.option('paging', { - enabled: true, - pageIndex: 0, - pageSize: 3 - }); - - // assert - assert.strictEqual(changedSpy.callCount, 0, 'changed is called'); - assert.strictEqual(loadingSpy.callCount, 0, 'loading is not called'); - }); - // T121445 QUnit.test('pager.allowedPageSizes change', function(assert) { // arrange, act