diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/selection.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/selection.test.ts index 56c842003265..48f5610cbc87 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/selection.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/selection.test.ts @@ -19,8 +19,8 @@ import { clearSelectionCommand, deselectAllCommand, selectAllCommand, - selectByIndexesCommand, selectByKeysCommand, + selectionByIndexesCommand, } from '../selection'; const createCallbacks = (): { @@ -31,6 +31,17 @@ const createCallbacks = (): { failure: jest.fn((message?: string) => ({ status: 'failure' as const, message: message ?? '' })), }); +// The local "allPages" path calls loadAll(), which defers behind the grid's +// loading timer. Under fake timers that timer must be advanced while the +// command is in flight, otherwise the awaited result never settles. +const executeWithTimers = async ( + run: () => Promise, +): Promise => { + const promise = run(); + await jest.runAllTimersAsync(); + return promise; +}; + const createGrid = async ( options: Record = {}, ): Promise => { @@ -67,6 +78,13 @@ const createCompositeGrid = ( ...options, }); +const createRemoteGrid = ( + options: Record = {}, +): Promise => createGrid({ + remoteOperations: { paging: true, filtering: true, sorting: true }, + ...options, +}); + describe('selectByKeysCommand', () => { beforeEach(() => beforeTest()); afterEach(() => afterTest()); @@ -232,7 +250,7 @@ describe('selectByKeysCommand', () => { it('returns failure when selectRows rejects', async () => { const instance = await createGrid(); - jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.reject(new Error('Error')) as never); + jest.spyOn(instance, 'selectRows').mockRejectedValue(new Error('Error') as never); const callbacks = createCallbacks(); const result = await selectByKeysCommand.execute(instance, callbacks)({ @@ -266,238 +284,568 @@ describe('selectByKeysCommand', () => { }); }); -describe('selectByIndexesCommand', () => { +describe('selectionByIndexesCommand', () => { beforeEach(() => beforeTest()); afterEach(() => afterTest()); describe('schema', () => { it('accepts an array of positive integers with mode deselect', () => { - expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1, 2, 3], mode: 'deselect', + expect(selectionByIndexesCommand.schema.safeParse({ + indexes: [1, 2, 3], mode: 'deselect', scope: 'allPages', }).success).toBe(true); }); it('accepts mode select', () => { - expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1], mode: 'select', + expect(selectionByIndexesCommand.schema.safeParse({ + indexes: [1], mode: 'select', scope: 'allPages', }).success).toBe(true); }); it('rejects when indexes is missing', () => { - expect(selectByIndexesCommand.schema.safeParse({ mode: 'select' }).success).toBe(false); + expect(selectionByIndexesCommand.schema.safeParse({ + mode: 'select', scope: 'allPages', + }).success).toBe(false); }); it('rejects when mode is missing', () => { - expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1], + expect(selectionByIndexesCommand.schema.safeParse({ + indexes: [1], scope: 'allPages', }).success).toBe(false); }); it('rejects an invalid mode value', () => { - expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1], mode: 'toggle', + expect(selectionByIndexesCommand.schema.safeParse({ + indexes: [1], mode: 'toggle', scope: 'allPages', }).success).toBe(false); }); it('rejects when indexes is an empty array', () => { - expect(selectByIndexesCommand.schema.safeParse({ - indexes: [], mode: 'select', + expect(selectionByIndexesCommand.schema.safeParse({ + indexes: [], mode: 'select', scope: 'allPages', }).success).toBe(false); }); it('rejects zero (indexes are 1-based)', () => { - expect(selectByIndexesCommand.schema.safeParse({ - indexes: [0], mode: 'select', + expect(selectionByIndexesCommand.schema.safeParse({ + indexes: [0], mode: 'select', scope: 'allPages', }).success).toBe(false); }); it('rejects negative indexes', () => { - expect(selectByIndexesCommand.schema.safeParse({ - indexes: [-1], mode: 'select', + expect(selectionByIndexesCommand.schema.safeParse({ + indexes: [-1], mode: 'select', scope: 'allPages', }).success).toBe(false); }); it('rejects non-integer indexes', () => { - expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1.5], mode: 'select', + expect(selectionByIndexesCommand.schema.safeParse({ + indexes: [1.5], mode: 'select', scope: 'allPages', }).success).toBe(false); }); it('rejects unknown properties', () => { - expect(selectByIndexesCommand.schema.safeParse({ + expect(selectionByIndexesCommand.schema.safeParse({ indexes: [1], mode: 'select', + scope: 'allPages', extra: 1, }).success).toBe(false); }); + + it('rejects when scope is missing', () => { + expect(selectionByIndexesCommand.schema.safeParse({ + indexes: [1], mode: 'select', + }).success).toBe(false); + }); + + it('accepts scope "allPages"', () => { + expect(selectionByIndexesCommand.schema.safeParse({ + indexes: [1], mode: 'select', scope: 'allPages', + }).success).toBe(true); + }); + + it('accepts scope "page"', () => { + expect(selectionByIndexesCommand.schema.safeParse({ + indexes: [1], mode: 'select', scope: 'page', + }).success).toBe(true); + }); + + it('rejects an invalid scope value', () => { + expect(selectionByIndexesCommand.schema.safeParse({ + indexes: [1], mode: 'select', scope: 'global', + }).success).toBe(false); + }); }); describe('execute', () => { it('returns failure when selection.mode is none', async () => { const instance = await createGrid({ selection: { mode: 'none' } }); - const selectSpy = jest.spyOn(instance, 'selectRowsByIndexes'); + const selectSpy = jest.spyOn(instance, 'selectRows'); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', }); expect(result.status).toBe('failure'); expect(selectSpy).not.toHaveBeenCalled(); }); - it('returns failure when any index has no row on the current page', async () => { - const instance = await createGrid(); - const selectSpy = jest.spyOn(instance, 'selectRowsByIndexes'); - const callbacks = createCallbacks(); + describe('scope "allPages" — remote paging', () => { + it('returns failure when dataSource/store is missing', async () => { + const instance = await createRemoteGrid(); + jest.spyOn(instance, 'getDataSource').mockReturnValue(undefined as never); + const selectSpy = jest.spyOn(instance, 'selectRows'); + const callbacks = createCallbacks(); + + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', + }); - // Three rows in createGrid; 1-based index 100 has no row on the current page. - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 100], mode: 'select', + expect(result.status).toBe('failure'); + expect(selectSpy).not.toHaveBeenCalled(); }); - expect(result.status).toBe('failure'); - expect(selectSpy).not.toHaveBeenCalled(); - }); + it('returns failure (without loading) when no key is configured', async () => { + const instance = await createRemoteGrid(); + const realOption = instance.option.bind(instance); + jest.spyOn(instance, 'option').mockImplementation(((...callArgs: unknown[]): unknown => { + if (callArgs.length === 1 && callArgs[0] === 'keyExpr') { + return undefined; + } + return (realOption as (...a: unknown[]) => unknown)(...callArgs); + }) as never); + const store = instance.getDataSource().store(); + jest.spyOn(store, 'key').mockReturnValue(undefined as never); + const loadSpy = jest.spyOn(store, 'load'); + const selectSpy = jest.spyOn(instance, 'selectRows'); + const callbacks = createCallbacks(); + + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 2], mode: 'select', scope: 'allPages', + }); + + expect(result.status).toBe('failure'); + expect(loadSpy).not.toHaveBeenCalled(); + expect(selectSpy).not.toHaveBeenCalled(); + }); - it('returns failure when any index points at a non-data row (e.g. group row)', async () => { - // Grouping by `name` produces group rows interleaved with data rows. - // 1-based index 1 (→ 0 after normalization) is a group row → command rejects the entire set - const instance = await createGrid({ - columns: [ - { dataField: 'id', dataType: 'number' }, - { dataField: 'name', dataType: 'string', groupIndex: 0 }, - ], + it('loads a contiguous range via store.load with skip/take and selects resolved keys with preserve=true', async () => { + const instance = await createRemoteGrid(); + const store = instance.getDataSource().store(); + const loadSpy = jest.spyOn(store, 'load').mockReturnValue( + Promise.resolve([{ id: 2, name: 'Beta' }, { id: 3, name: 'Gamma' }]) as never, + ); + const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); + const callbacks = createCallbacks(); + + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [2, 3], mode: 'select', scope: 'allPages', + }); + + expect(loadSpy).toHaveBeenCalledTimes(1); + const loadArg = loadSpy.mock.calls[0][0] as { skip: number; take: number }; + expect(loadArg.skip).toBe(1); + expect(loadArg.take).toBe(2); + expect(selectSpy).toHaveBeenCalledWith([2, 3], true); + expect(result.status).toBe('success'); }); - const selectSpy = jest.spyOn(instance, 'selectRowsByIndexes'); - const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', + it('applies the combined filter (not just the base dataSource filter) to store.load', async () => { + const instance = await createRemoteGrid({ + columns: [ + { dataField: 'id', dataType: 'number' }, + { + dataField: 'name', dataType: 'string', filterValue: 'Beta', selectedFilterOperation: '=', + }, + ], + filterRow: { visible: true }, + }); + const store = instance.getDataSource().store(); + const loadSpy = jest.spyOn(store, 'load').mockReturnValue( + Promise.resolve([{ id: 2, name: 'Beta' }]) as never, + ); + jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); + const callbacks = createCallbacks(); + + await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', + }); + + const loadArg = loadSpy.mock.calls[0][0] as { filter: unknown }; + expect(JSON.stringify(loadArg.filter)).toContain('Beta'); }); - expect(result.status).toBe('failure'); - expect(selectSpy).not.toHaveBeenCalled(); - }); + it('coalesces nearby indexes into a single store.load and picks the requested offsets', async () => { + const instance = await createRemoteGrid(); + const store = instance.getDataSource().store(); + const loadSpy = jest.spyOn(store, 'load').mockReturnValue( + Promise.resolve([ + { id: 1, name: 'Alpha' }, + { id: 2, name: 'Beta' }, + { id: 3, name: 'Gamma' }, + { id: 4, name: 'Delta' }, + { id: 5, name: 'Epsilon' }, + ]) as never, + ); + const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); + const callbacks = createCallbacks(); + + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 2, 5], mode: 'select', scope: 'allPages', + }); + + expect(loadSpy).toHaveBeenCalledTimes(1); + const loadArg = loadSpy.mock.calls[0][0] as { skip: number; take: number }; + expect(loadArg.skip).toBe(0); + expect(loadArg.take).toBe(5); + // Only the requested offsets (0, 1, 4) are selected; gap rows are dropped. + expect(selectSpy).toHaveBeenCalledWith([1, 2, 5], true); + expect(result.status).toBe('success'); + }); - it('normalizes 1-based input to 0-based when calling selectRowsByIndexes', async () => { - const instance = await createGrid(); - const selectSpy = jest.spyOn(instance, 'selectRowsByIndexes').mockReturnValue(Promise.resolve([]) as never); - const callbacks = createCallbacks(); + it('splits indexes wider than the load window into separate store.load calls', async () => { + const instance = await createRemoteGrid(); + const store = instance.getDataSource().store(); + const loadSpy = jest.spyOn(store, 'load').mockImplementation((options: unknown) => { + const { skip } = options as { skip: number; take: number }; + return skip === 0 + ? Promise.resolve([{ id: 1, name: 'Alpha' }]) as never + : Promise.resolve([{ id: 99, name: 'Far' }]) as never; + }); + const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); + const callbacks = createCallbacks(); + + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 5000], mode: 'select', scope: 'allPages', + }); + + expect(loadSpy).toHaveBeenCalledTimes(2); + expect(selectSpy).toHaveBeenCalledWith([1, 99], true); + expect(result.status).toBe('success'); + }); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 3], mode: 'select', + it('returns failure (without loading) when grouping is active', async () => { + const instance = await createRemoteGrid({ + columns: [ + { dataField: 'id', dataType: 'number' }, + { dataField: 'name', dataType: 'string', groupIndex: 0 }, + ], + }); + const loadSpy = jest.spyOn(instance.getDataSource().store(), 'load'); + const selectSpy = jest.spyOn(instance, 'selectRows'); + const callbacks = createCallbacks(); + + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', + }); + + expect(result.status).toBe('failure'); + expect(loadSpy).not.toHaveBeenCalled(); + expect(selectSpy).not.toHaveBeenCalled(); }); - expect(selectSpy).toHaveBeenCalledWith([0, 2]); - expect(result.status).toBe('success'); - }); + it('returns failure when store.load returns fewer rows than requested (range exceeds dataset)', async () => { + const instance = await createRemoteGrid(); + const store = instance.getDataSource().store(); + jest.spyOn(store, 'load').mockReturnValue( + Promise.resolve([{ id: 1, name: 'Alpha' }]) as never, + ); + const selectSpy = jest.spyOn(instance, 'selectRows'); + const callbacks = createCallbacks(); + + // Three rows in createGrid; range 1..10 cannot be fully resolved. + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], mode: 'select', scope: 'allPages', + }); + + expect(result.status).toBe('failure'); + expect(selectSpy).not.toHaveBeenCalled(); + }); - it('selects when mode is select', async () => { - const instance = await createGrid(); - const selectSpy = jest.spyOn(instance, 'selectRowsByIndexes').mockReturnValue(Promise.resolve([]) as never); - const deselectSpy = jest.spyOn(instance, 'deselectRows'); - const callbacks = createCallbacks(); + it('resolves keys via store.load and calls deselectRows when deselecting', async () => { + const instance = await createRemoteGrid(); + const store = instance.getDataSource().store(); + jest.spyOn(store, 'load').mockReturnValue( + Promise.resolve([{ id: 1, name: 'Alpha' }]) as never, + ); + const deselectSpy = jest.spyOn(instance, 'deselectRows').mockReturnValue(Promise.resolve([]) as never); + const selectSpy = jest.spyOn(instance, 'selectRows'); + const callbacks = createCallbacks(); + + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'deselect', scope: 'allPages', + }); + + expect(deselectSpy).toHaveBeenCalledWith([1]); + expect(selectSpy).not.toHaveBeenCalled(); + expect(result.status).toBe('success'); + }); + + it('returns failure when store.load rejects', async () => { + const instance = await createRemoteGrid(); + jest.spyOn(instance.getDataSource().store(), 'load') + .mockRejectedValue(new Error('Error') as never); + const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 3], mode: 'select', + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', + }); + + expect(result.status).toBe('failure'); }); - expect(selectSpy).toHaveBeenCalledWith([0, 2]); - expect(deselectSpy).not.toHaveBeenCalled(); - expect(result.status).toBe('success'); - }); + it('returns failure when selectRows rejects', async () => { + const instance = await createRemoteGrid(); + jest.spyOn(instance.getDataSource().store(), 'load').mockReturnValue( + Promise.resolve([{ id: 1, name: 'Alpha' }]) as never, + ); + jest.spyOn(instance, 'selectRows') + .mockRejectedValue(new Error('Error') as never); + const callbacks = createCallbacks(); - it('resolves indexes to row keys and calls deselectRows when deselecting', async () => { - const instance = await createGrid(); - const deselectSpy = jest.spyOn(instance, 'deselectRows').mockReturnValue(Promise.resolve([]) as never); - const selectSpy = jest.spyOn(instance, 'selectRowsByIndexes'); - const callbacks = createCallbacks(); + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', + }); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'deselect', + expect(result.status).toBe('failure'); }); - expect(deselectSpy).toHaveBeenCalledWith([1]); - expect(selectSpy).not.toHaveBeenCalled(); - expect(result.status).toBe('success'); + it('returns failure when deselectRows rejects', async () => { + const instance = await createRemoteGrid(); + jest.spyOn(instance.getDataSource().store(), 'load').mockReturnValue( + Promise.resolve([{ id: 1, name: 'Alpha' }]) as never, + ); + jest.spyOn(instance, 'deselectRows') + .mockRejectedValue(new Error('Error') as never); + const callbacks = createCallbacks(); + + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'deselect', scope: 'allPages', + }); + + expect(result.status).toBe('failure'); + }); }); - it('returns failure when selectRowsByIndexes throws', async () => { - const instance = await createGrid(); - jest.spyOn(instance, 'selectRowsByIndexes').mockImplementation(() => { - throw new Error('Error'); + describe('scope "allPages" — local paging', () => { + it('resolves keys via loadAll (no store.load) and selects with preserve=true', async () => { + const instance = await createGrid(); + const loadAllSpy = jest.spyOn(instance.getController('data'), 'loadAll'); + const loadSpy = jest.spyOn(instance.getDataSource().store(), 'load'); + const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); + const callbacks = createCallbacks(); + + const result = await executeWithTimers( + () => selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 3], mode: 'select', scope: 'allPages', + }), + ); + + expect(loadAllSpy).toHaveBeenCalled(); + expect(loadSpy).not.toHaveBeenCalled(); + expect(selectSpy).toHaveBeenCalledWith([1, 3], true); + expect(result.status).toBe('success'); }); - const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', + it('resolves keys via loadAll and calls deselectRows when deselecting', async () => { + const instance = await createGrid(); + const deselectSpy = jest.spyOn(instance, 'deselectRows').mockReturnValue(Promise.resolve([]) as never); + const callbacks = createCallbacks(); + + const result = await executeWithTimers( + () => selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [2], mode: 'deselect', scope: 'allPages', + }), + ); + + expect(deselectSpy).toHaveBeenCalledWith([2]); + expect(result.status).toBe('success'); }); - expect(result.status).toBe('failure'); - }); + it('returns failure when an index exceeds the dataset', async () => { + const instance = await createGrid(); + const selectSpy = jest.spyOn(instance, 'selectRows'); + const callbacks = createCallbacks(); - it('returns failure when selectRowsByIndexes rejects', async () => { - const instance = await createGrid(); - jest.spyOn(instance, 'selectRowsByIndexes') - .mockReturnValue(Promise.reject(new Error('Error')) as never); - const callbacks = createCallbacks(); + const result = await executeWithTimers( + () => selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 100], mode: 'select', scope: 'allPages', + }), + ); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', + expect(result.status).toBe('failure'); + expect(selectSpy).not.toHaveBeenCalled(); }); - expect(result.status).toBe('failure'); + it('skips group rows so indexes address data rows only', async () => { + const instance = await createGrid({ + columns: [ + { dataField: 'id', dataType: 'number' }, + { dataField: 'name', dataType: 'string', groupIndex: 0 }, + ], + }); + const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); + const callbacks = createCallbacks(); + + const result = await executeWithTimers( + () => selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', + }), + ); + + expect(selectSpy).toHaveBeenCalledWith([1], true); + expect(result.status).toBe('success'); + }); + + it('indexes within the filtered dataset (combined filter applied via loadAll)', async () => { + const instance = await createGrid({ + columns: [ + { dataField: 'id', dataType: 'number' }, + { + dataField: 'name', dataType: 'string', filterValue: 'Beta', selectedFilterOperation: '=', + }, + ], + filterRow: { visible: true }, + }); + const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); + const callbacks = createCallbacks(); + + const result = await executeWithTimers( + () => selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', + }), + ); + + expect(selectSpy).toHaveBeenCalledWith([2], true); + expect(result.status).toBe('success'); + }); + + it('returns failure when selectRows rejects', async () => { + const instance = await createGrid(); + jest.spyOn(instance, 'selectRows') + .mockRejectedValue(new Error('Error') as never); + const callbacks = createCallbacks(); + + const result = await executeWithTimers( + () => selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', + }), + ); + + expect(result.status).toBe('failure'); + }); }); - it('returns failure when deselectRows rejects', async () => { - const instance = await createGrid(); - jest.spyOn(instance, 'deselectRows') - .mockReturnValue(Promise.reject(new Error('Error')) as never); - const callbacks = createCallbacks(); + describe('scope "page"', () => { + it('resolves keys from the current page items (no store.load) and selects with preserve=true', async () => { + const instance = await createGrid(); + const loadSpy = jest.spyOn(instance.getDataSource().store(), 'load'); + const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); + const callbacks = createCallbacks(); + + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 3], mode: 'select', scope: 'page', + }); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'deselect', + expect(loadSpy).not.toHaveBeenCalled(); + expect(selectSpy).toHaveBeenCalledWith([1, 3], true); + expect(result.status).toBe('success'); }); - expect(result.status).toBe('failure'); + it('resolves keys from the current page items and calls deselectRows when deselecting', async () => { + const instance = await createGrid(); + const deselectSpy = jest.spyOn(instance, 'deselectRows').mockReturnValue(Promise.resolve([]) as never); + const callbacks = createCallbacks(); + + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'deselect', scope: 'page', + }); + + expect(deselectSpy).toHaveBeenCalledWith([1]); + expect(result.status).toBe('success'); + }); + + it('returns failure when any index has no row on the current page', async () => { + const instance = await createGrid(); + const selectSpy = jest.spyOn(instance, 'selectRows'); + const callbacks = createCallbacks(); + + // Three rows in createGrid; 1-based index 100 has no row on the current page. + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 100], mode: 'select', scope: 'page', + }); + + expect(result.status).toBe('failure'); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + it('counts data rows only, skipping interleaved group rows', async () => { + const instance = await createGrid({ + columns: [ + { dataField: 'id', dataType: 'number' }, + { dataField: 'name', dataType: 'string', groupIndex: 0 }, + ], + }); + const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); + const callbacks = createCallbacks(); + + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'page', + }); + + expect(selectSpy).toHaveBeenCalledWith([1], true); + expect(result.status).toBe('success'); + }); }); }); describe('default message', () => { - it('reports the 1-based row numbers on the current page on select', async () => { + it('reports the 1-based row numbers on select', async () => { const instance = await createGrid(); - jest.spyOn(instance, 'selectRowsByIndexes').mockReturnValue(Promise.resolve([]) as never); + jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 3], mode: 'select', - }); + await executeWithTimers(() => selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 3], mode: 'select', scope: 'allPages', + })); - expect(callbacks.success).toHaveBeenCalledWith('Select row(s) number 1, 3 on the current page.'); + expect(callbacks.success).toHaveBeenCalledWith('Select row(s) number 1, 3.'); }); - it('reports the 1-based row numbers on the current page on deselect', async () => { + it('reports the 1-based row numbers on deselect', async () => { const instance = await createGrid(); jest.spyOn(instance, 'deselectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'deselect', - }); + await executeWithTimers(() => selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'deselect', scope: 'allPages', + })); - expect(callbacks.success).toHaveBeenCalledWith('Deselect row(s) number 1 on the current page.'); + expect(callbacks.success).toHaveBeenCalledWith('Deselect row(s) number 1.'); }); it('passes the same default message to failure', async () => { const instance = await createGrid({ selection: { mode: 'none' } }); const callbacks = createCallbacks(); - await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', + await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', + }); + + expect(callbacks.failure).toHaveBeenCalledWith('Select row(s) number 1.'); + }); + + it('appends "on the current page" when scope is "page"', async () => { + const instance = await createGrid(); + jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); + const callbacks = createCallbacks(); + + await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 2], mode: 'select', scope: 'page', }); - expect(callbacks.failure).toHaveBeenCalledWith('Select row(s) number 1 on the current page.'); + expect(callbacks.success).toHaveBeenCalledWith('Select row(s) number 1, 2 on the current page.'); }); }); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/utils.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/utils.test.ts index ea3ff6065cf7..8e43678aac8d 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/utils.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/utils.test.ts @@ -6,8 +6,12 @@ import { import { z } from 'zod'; import { + isKeyShapeValid, + normalizeKey, // eslint-disable-next-line spellcheck/spell-checker - isKeyShapeValid, normalizeKey, optionalNullish, resolveFilterValue, + optionalNullish, + resolveFilterValue, + splitIntoLoadWindows, } from '../utils'; describe('normalizeKey', () => { @@ -168,3 +172,33 @@ describe('resolveFilterValue', () => { expect(resolveFilterValue('date', true)).toBe(true); }); }); + +describe('splitIntoLoadWindows', () => { + it('returns an empty array for an empty input', () => { + expect(splitIntoLoadWindows([], 10)).toEqual([]); + }); + + it('wraps a single index into a single window', () => { + expect(splitIntoLoadWindows([5], 10)).toEqual([[5]]); + }); + + it('merges across gaps while the span stays within the window', () => { + expect(splitIntoLoadWindows([1, 3, 5], 10)).toEqual([[1, 3, 5]]); + }); + + it('starts a new window when the span would exceed the limit', () => { + expect(splitIntoLoadWindows([1, 2, 4, 5, 6, 10], 3)).toEqual([ + [1, 2], [4, 5, 6], [10], + ]); + }); + + it('sorts unsorted input before windowing', () => { + expect(splitIntoLoadWindows([5, 1, 6, 2, 10, 7], 3)).toEqual([ + [1, 2], [5, 6, 7], [10], + ]); + }); + + it('deduplicates repeated indexes', () => { + expect(splitIntoLoadWindows([1, 1, 2, 2, 3], 10)).toEqual([[1, 2, 3]]); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts index 2db7290654a2..36303451fe4f 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts @@ -25,8 +25,8 @@ import { clearSelectionCommand, deselectAllCommand, selectAllCommand, - selectByIndexesCommand, selectByKeysCommand, + selectionByIndexesCommand, } from './selection'; import { clearSortingCommand, @@ -49,7 +49,7 @@ export const coreCommands = [ clearSelectionCommand, deselectAllCommand, selectAllCommand, - selectByIndexesCommand, + selectionByIndexesCommand, selectByKeysCommand, clearSortingCommand, sortingCommand, diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts index da2c1173aa76..4fbdf4bed23e 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts @@ -1,11 +1,17 @@ import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import type { InternalGrid, RowKey } from '@ts/grids/grid_core/m_types'; import { z } from 'zod'; import { defineGridCommand } from './defineGridCommand'; import { - compositeKeyPairSchema, isKeyShapeValid, normalizeKey, + compositeKeyPairSchema, + isKeyShapeValid, + normalizeKey, + splitIntoLoadWindows, } from './utils'; +const MAX_LOAD_WINDOW_PAGES = 5; + const selectByKeysCommandSchema = z.object({ keys: z.array(z.union([ z.string(), @@ -45,51 +51,167 @@ export const selectByKeysCommand = defineGridCommand({ }, }); -const selectByIndexesCommandSchema = z.object({ +const selectionByIndexesCommandSchema = z.object({ indexes: z.array(z.number().int().min(1)).min(1), mode: z.enum(['select', 'deselect']), + scope: z.enum(['allPages', 'page']), }).strict(); -export const selectByIndexesCommand = defineGridCommand({ - name: 'selectByIndexes', - description: 'Select or deselect specific rows by their 1-based indexes within the current page. ' - + 'Index 1 is the first row on the visible page; group/header rows are not addressable. ' - + 'Set mode to "deselect" to remove the listed rows from the current selection (e.g. "unselect row 1"); set it to "select" to select them. ' - + 'When mode is "select", the listed rows replace the current selection. ' - + 'To target rows that are not on the current page, use selectByKeys, or call pageIndex first to switch the page. ' - + 'To clear selection only within the current selectAll scope, use deselectAll; to clear selection across all pages regardless of selectAllMode, use clearSelection.', - schema: selectByIndexesCommandSchema, +// Maps 1-based indexes to the keys at those positions; +// an index past the last entry rejects the whole set. +const pickKeysByIndex = ( + keys: RowKey[], + indexes: number[], +): RowKey[] | null => { + const normalizedRowIndexes = indexes.map((index) => index - 1); + const allIndexesValid = normalizedRowIndexes.every( + (index) => index < keys.length, + ); + + if (!allIndexesValid) { + return null; + } + + return normalizedRowIndexes.map((index) => keys[index]); +}; + +const resolveKeysFromCurrentPage = ( + component: InternalGrid, + indexes: number[], +): RowKey[] | null => { + // Group/footer rows are not counted, so indexes address the Nth data row. + const items = component.getController('data').items(); + const dataItems = items.filter((item) => item.rowType === 'data'); + const dataKeys = dataItems.map((item) => item.key); + + return pickKeysByIndex(dataKeys, indexes); +}; + +const resolveKeysFromAllPagesRemote = async ( + component: InternalGrid, + indexes: number[], +): Promise => { + const dataSource = component.getDataSource(); + const store = dataSource?.store(); + + if (!dataSource || !store) { + return null; + } + + const keyExpr = component.option('keyExpr') ?? store.key(); + + if (!keyExpr) { + return null; + } + + // Under grouping store.load returns group structures rather than flat rows, + // so an index no longer maps to a single data row. Fail instead of resolving meaningless keys + const grouping = dataSource.group(); + const isGrouped = Array.isArray(grouping) ? grouping.length > 0 : !!grouping; + + if (isGrouped) { + return null; + } + + const dataController = component.getController('data'); + const filter = dataController.getCombinedFilter(true); + const baseLoadOptions = { + ...dataSource.loadOptions(), + filter, + }; + + const windows = splitIntoLoadWindows(indexes, dataSource.pageSize() * MAX_LOAD_WINDOW_PAGES); + + const loadedWindows = await Promise.all(windows.map((window) => { + const skip = window[0] - 1; + const take = window[window.length - 1] - window[0] + 1; + return store.load({ ...baseLoadOptions, skip, take }) + .then((result) => { + const rows = Array.isArray(result) ? result : (result as { data: unknown[] }).data; + return { window, rows }; + }); + })); + + const keys: RowKey[] = []; + + for (const { window, rows } of loadedWindows) { + if (!Array.isArray(rows)) { + return null; + } + + for (const index of window) { + // The requested index maps to `window[0]` offset within the loaded rows + const row = rows[index - window[0]]; + + if (row === undefined) { + return null; + } + + keys.push(store.keyOf(row)); + } + } + + return keys; +}; + +const resolveKeysFromAllPagesLocal = async ( + component: InternalGrid, + indexes: number[], +): Promise => { + const keys = await component.getController('data').getAllDataRowKeys(); + + return pickKeysByIndex(keys, indexes); +}; + +// Picks the "allPages" strategy by paging mode: +// with local paging the full dataset is already on the client, so read the cache; +// with remote paging rows are fetched by position. +const resolveKeysFromAllPages = ( + component: InternalGrid, + indexes: number[], +): Promise => { + const dataController = component.getController('data'); + const isRemotePaging = !!dataController.dataSource()?.remoteOperations()?.paging; + + return isRemotePaging + ? resolveKeysFromAllPagesRemote(component, indexes) + : resolveKeysFromAllPagesLocal(component, indexes); +}; + +export const selectionByIndexesCommand = defineGridCommand({ + name: 'selectionByIndexes', + description: 'Select or deselect rows by their 1-based indexes. ' + + 'Indexes start at 1: "the first row" is index 1 and "the 5th row" is index 5. Do NOT use 0-based counting here, this command is 1-based. ' + + 'Always set scope to choose how indexes are interpreted: ' + + '"allPages" — indexes are positions within the currently filtered and sorted dataset, NOT limited to the current page; index 1 is the first row of the dataset, regardless of pageIndex/pageSize. Use this when the user does NOT explicitly refer to the visible page (e.g. "select rows 1 to 100"). ' + + '"page" — indexes are positions within the currently rendered page; index 1 is the first data row on the visible page and group/header rows are not counted. Use this ONLY when the user explicitly mentions the current/visible page (e.g. "select the first 3 rows on the current page", "deselect row 2 on this page"). ' + + 'Set mode to "select" to add the listed rows to the current selection (multiple calls accumulate, so previously selected ranges are kept); set mode to "deselect" to remove the listed rows from the current selection (e.g. "unselect row 1"). ' + + 'To clear selection only within the current selectAll scope, use deselectAll; to clear selection across all pages regardless of selectAllMode, use clearSelection. ' + + 'To target rows by key value rather than by index, use selectByKeys.', + schema: selectionByIndexesCommandSchema, execute: (component, { success, failure }) => async (args): Promise => { const rowIndexes = args.indexes.join(', '); const action = args.mode === 'deselect' ? 'Deselect' : 'Select'; - const defaultMessage = `${action} row(s) number ${rowIndexes} on the current page.`; + const scopeSuffix = args.scope === 'page' ? ' on the current page' : ''; + const defaultMessage = `${action} row(s) number ${rowIndexes}${scopeSuffix}.`; if (component.option('selection.mode') === 'none') { return failure(defaultMessage); } - const items = component.getController('data').items(); - const normalizedRowIndexes = args.indexes.map((index) => index - 1); - const allIndexesValid = normalizedRowIndexes.every( - (index) => items[index]?.rowType === 'data', - ); + try { + const keys = args.scope === 'page' + ? resolveKeysFromCurrentPage(component, args.indexes) + : await resolveKeysFromAllPages(component, args.indexes); - if (!allIndexesValid) { - return failure(defaultMessage); - } + if (keys === null) { + return failure(defaultMessage); + } - try { - switch (args.mode) { - case 'deselect': { - const itemKeys = normalizedRowIndexes.map((index) => items[index].key); - await component.deselectRows(itemKeys); - break; - } - case 'select': - await component.selectRowsByIndexes(normalizedRowIndexes); - break; - default: - return failure(defaultMessage); + if (args.mode === 'deselect') { + await component.deselectRows(keys); + } else { + await component.selectRows(keys, true); } return success(defaultMessage); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts index 4a2e7c521faf..74c275c7b532 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts @@ -67,6 +67,27 @@ export const isKeyShapeValid = ( return keyExpr.every((field) => field in key); }; +export const splitIntoLoadWindows = ( + indexes: number[], + maxWindowSize: number, +): number[][] => { + const sorted = [...new Set(indexes)].sort((a, b) => a - b); + const windows: number[][] = []; + + sorted.forEach((index) => { + const current = windows.at(-1); + + // Sorted indexes are merged into the same window within maxWindowSize + if (current && index - current[0] + 1 <= maxWindowSize) { + current.push(index); + } else { + windows.push([index]); + } + }); + + return windows; +}; + type FilterExprValue = BasicFilterExpr['value']; export function resolveFilterValue( diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/m_data_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/m_data_controller.ts index 55b8a1314917..c9b720bbf9a0 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_controller/m_data_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/m_data_controller.ts @@ -23,7 +23,7 @@ import type { ValidatingController } from '@ts/grids/grid_core/validating/m_vali import { AI_COLUMN_NAME } from '../ai_column/const'; import modules from '../m_modules'; import type { - Controllers, Module, + Controllers, Module, RowKey, } from '../m_types'; import gridCoreUtils from '../m_utils'; import type { VirtualScrollController } from '../virtual_scrolling/m_virtual_scrolling_core'; @@ -1542,6 +1542,13 @@ export class DataController extends DataHelperMixin(modules.Controller) { return d; } + public getAllDataRowKeys(): Promise { + return Promise.resolve(this.loadAll(undefined) as unknown as Promise) + .then((items) => items + .filter((item) => item.rowType === 'data') + .map((item) => item.key)); + } + public getKeyByRowIndex(rowIndex, byLoaded?) { const item = this.items(byLoaded)[rowIndex]; if (item) { diff --git a/packages/devextreme/js/__internal/grids/tree_list/module_not_extended/ai_assistant.ts b/packages/devextreme/js/__internal/grids/tree_list/ai_assistant/ai_assistant.ts similarity index 100% rename from packages/devextreme/js/__internal/grids/tree_list/module_not_extended/ai_assistant.ts rename to packages/devextreme/js/__internal/grids/tree_list/ai_assistant/ai_assistant.ts diff --git a/packages/devextreme/js/__internal/grids/tree_list/ai_assistant/commands/selection.integration.test.ts b/packages/devextreme/js/__internal/grids/tree_list/ai_assistant/commands/selection.integration.test.ts new file mode 100644 index 000000000000..69d6f14ba955 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/tree_list/ai_assistant/commands/selection.integration.test.ts @@ -0,0 +1,85 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import type { Properties as TreeListProperties } from '@js/ui/tree_list'; +import TreeList from '@js/ui/tree_list'; +import { selectionByIndexesCommand } from '@ts/grids/grid_core/ai_assistant/commands/selection'; +import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import type { InternalGrid } from '@ts/grids/grid_core/m_types'; + +const CONTAINER_ID = 'treeListContainer'; + +// node 1 (root) → node 2 (child); node 3 (root). Depth-first key order: 1, 2, 3. +const items = [ + { id: 1, parentId: 0, name: 'Name 1' }, + { id: 2, parentId: 1, name: 'Name 2' }, + { id: 3, parentId: 0, name: 'Name 3' }, +]; + +const createCallbacks = (): { + success: jest.Mock<(message?: string) => CommandResult>; + failure: jest.Mock<(message?: string) => CommandResult>; +} => ({ + success: jest.fn((message?: string) => ({ status: 'success' as const, message: message ?? '' })), + failure: jest.fn((message?: string) => ({ status: 'failure' as const, message: message ?? '' })), +}); + +const createTreeList = ( + options: TreeListProperties = {}, +): Promise => new Promise((resolve) => { + const $container = $('
').attr('id', CONTAINER_ID).appendTo(document.body); + + const instance = new TreeList($container.get(0) as HTMLDivElement, { + dataSource: items, + keyExpr: 'id', + parentIdExpr: 'parentId', + autoExpandAll: true, + selection: { mode: 'multiple' }, + ...options, + }); + + jest.runAllTimers(); + + resolve(instance as unknown as InternalGrid); +}); + +describe('selectionByIndexesCommand on TreeList — "allPages" scope', () => { + beforeEach(async () => jest.useFakeTimers()); + + afterEach(() => { + const $container = $(`#${CONTAINER_ID}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (($container as any).dxTreeList('instance') as TreeList | undefined)?.dispose(); + $container.remove(); + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it('resolves keys by walking the node tree (no loadAll pipeline crash)', async () => { + const instance = await createTreeList(); + const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); + const callbacks = createCallbacks(); + + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 3], mode: 'select', scope: 'allPages', + }); + + // Depth-first node keys are [1, 2, 3]; positions 1 and 3 are keys 1 and 3. + expect(selectSpy).toHaveBeenCalledWith([1, 3], true); + expect(result.status).toBe('success'); + }); + + it('returns failure when an index exceeds the loaded node count', async () => { + const instance = await createTreeList(); + const selectSpy = jest.spyOn(instance, 'selectRows'); + const callbacks = createCallbacks(); + + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ + indexes: [99], mode: 'select', scope: 'allPages', + }); + + expect(result.status).toBe('failure'); + expect(selectSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_controller/m_data_controller.ts b/packages/devextreme/js/__internal/grids/tree_list/data_controller/m_data_controller.ts index ff5b4f0aeecc..5091bd8a65f7 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/data_controller/m_data_controller.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/data_controller/m_data_controller.ts @@ -2,6 +2,7 @@ import { equalByValue } from '@js/core/utils/common'; import { Deferred } from '@js/core/utils/deferred'; import { extend } from '@js/core/utils/extend'; import { DataController, dataControllerModule } from '@ts/grids/grid_core/data_controller/m_data_controller'; +import type { RowKey } from '@ts/grids/grid_core/m_types'; import dataSourceAdapterProvider from '../data_source_adapter/m_data_source_adapter'; import treeListCore from '../m_core'; @@ -195,6 +196,18 @@ export class TreeListDataController extends DataController { private forEachNode() { this._dataSource.forEachNode.apply(this, arguments); } + + // Collect keys by walking the loaded node tree (depth-first, parent before + // children) — the order rows appear in when fully expanded. + public getAllDataRowKeys(): Promise { + const keys: RowKey[] = []; + + this._dataSource?.forEachNode((node) => { + keys.push(node.key); + }); + + return Promise.resolve(keys); + } } treeListCore.registerModule('data', { diff --git a/packages/devextreme/js/__internal/grids/tree_list/m_widget.ts b/packages/devextreme/js/__internal/grids/tree_list/m_widget.ts index 81fbd8d7b797..2a89ba216c6f 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/m_widget.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/m_widget.ts @@ -30,6 +30,6 @@ import './module_not_extended/virtual_columns'; import './m_focus'; import './module_not_extended/row_dragging'; import './module_not_extended/toast'; -import './module_not_extended/ai_assistant'; +import './ai_assistant/ai_assistant'; export default TreeList; diff --git a/packages/devextreme/js/common/grids.d.ts b/packages/devextreme/js/common/grids.d.ts index 72d53ff536c5..9cdecac4833d 100644 --- a/packages/devextreme/js/common/grids.d.ts +++ b/packages/devextreme/js/common/grids.d.ts @@ -220,9 +220,10 @@ export type PredefinedCommands = { keys: Array>; preserve: boolean; }; - selectByIndexes: { + selectionByIndexes: { indexes: number[]; mode: 'select' | 'deselect'; + scope: 'page' | 'allPages'; }; selectAll: {}; deselectAll: {}; diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index be00d6be8554..16a186a1c8f7 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -6609,9 +6609,10 @@ declare module DevExpress.common.grids { keys: Array>; preserve: boolean; }; - selectByIndexes: { + selectionByIndexes: { indexes: number[]; mode: 'select' | 'deselect'; + scope: 'page' | 'allPages'; }; selectAll: {}; deselectAll: {};