From cdcd1db9b9902f61305c1343c84e7e47920ab242 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 4 Jun 2026 18:09:17 +0400 Subject: [PATCH 1/8] AI Assistant: Add possibility to select/deselect rows on any page --- .../commands/__tests__/selection.test.ts | 117 +++++++++++------- .../commands/__tests__/utils.test.ts | 40 +++++- .../ai_assistant/commands/selection.ts | 67 ++++++---- .../grid_core/ai_assistant/commands/utils.ts | 21 ++++ 4 files changed, 176 insertions(+), 69 deletions(-) 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..ca8b0183df12 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 @@ -335,7 +335,7 @@ describe('selectByIndexesCommand', () => { 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)({ @@ -346,72 +346,92 @@ describe('selectByIndexesCommand', () => { expect(selectSpy).not.toHaveBeenCalled(); }); - it('returns failure when any index has no row on the current page', async () => { + it('returns failure when dataSource/store is missing', async () => { const instance = await createGrid(); - const selectSpy = jest.spyOn(instance, 'selectRowsByIndexes'); + jest.spyOn(instance, 'getDataSource').mockReturnValue(undefined as never); + 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 selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 100], mode: 'select', + indexes: [1], mode: 'select', }); expect(result.status).toBe('failure'); 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 }, - ], - }); - const selectSpy = jest.spyOn(instance, 'selectRowsByIndexes'); + it('loads a contiguous range via store.load with skip/take and selects resolved keys with preserve=true', async () => { + const instance = await createGrid(); + 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 selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', + indexes: [2, 3], mode: 'select', }); - expect(result.status).toBe('failure'); - expect(selectSpy).not.toHaveBeenCalled(); + 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'); }); - it('normalizes 1-based input to 0-based when calling selectRowsByIndexes', async () => { + it('splits non-contiguous indexes into multiple store.load calls and aggregates keys', async () => { const instance = await createGrid(); - const selectSpy = jest.spyOn(instance, 'selectRowsByIndexes').mockReturnValue(Promise.resolve([]) as never); + const store = instance.getDataSource().store(); + const loadSpy = jest.spyOn(store, 'load').mockImplementation((options: unknown) => { + const { skip, take } = options as { skip: number; take: number }; + if (skip === 0 && take === 2) { + return Promise.resolve([{ id: 1, name: 'Alpha' }, { id: 2, name: 'Beta' }]) as never; + } + if (skip === 4 && take === 1) { + return Promise.resolve([{ id: 5, name: 'Epsilon' }]) as never; + } + return Promise.resolve([]) as never; + }); + const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 3], mode: 'select', + indexes: [1, 2, 5], mode: 'select', }); - expect(selectSpy).toHaveBeenCalledWith([0, 2]); + expect(loadSpy).toHaveBeenCalledTimes(2); + expect(selectSpy).toHaveBeenCalledWith([1, 2, 5], true); expect(result.status).toBe('success'); }); - it('selects when mode is select', async () => { + it('returns failure when store.load returns fewer rows than requested (range exceeds dataset)', async () => { const instance = await createGrid(); - const selectSpy = jest.spyOn(instance, 'selectRowsByIndexes').mockReturnValue(Promise.resolve([]) as never); - const deselectSpy = jest.spyOn(instance, 'deselectRows'); + 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 selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 3], mode: 'select', + indexes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], mode: 'select', }); - expect(selectSpy).toHaveBeenCalledWith([0, 2]); - expect(deselectSpy).not.toHaveBeenCalled(); - expect(result.status).toBe('success'); + expect(result.status).toBe('failure'); + expect(selectSpy).not.toHaveBeenCalled(); }); - it('resolves indexes to row keys and calls deselectRows when deselecting', async () => { + it('resolves keys via store.load and calls deselectRows when deselecting', async () => { const instance = await createGrid(); + 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, 'selectRowsByIndexes'); + const selectSpy = jest.spyOn(instance, 'selectRows'); const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ @@ -423,11 +443,10 @@ describe('selectByIndexesCommand', () => { expect(result.status).toBe('success'); }); - it('returns failure when selectRowsByIndexes throws', async () => { + it('returns failure when store.load rejects', async () => { const instance = await createGrid(); - jest.spyOn(instance, 'selectRowsByIndexes').mockImplementation(() => { - throw new Error('Error'); - }); + jest.spyOn(instance.getDataSource().store(), 'load') + .mockReturnValue(Promise.reject(new Error('Error')) as never); const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ @@ -437,9 +456,12 @@ describe('selectByIndexesCommand', () => { expect(result.status).toBe('failure'); }); - it('returns failure when selectRowsByIndexes rejects', async () => { + it('returns failure when selectRows rejects', async () => { const instance = await createGrid(); - jest.spyOn(instance, 'selectRowsByIndexes') + jest.spyOn(instance.getDataSource().store(), 'load').mockReturnValue( + Promise.resolve([{ id: 1, name: 'Alpha' }]) as never, + ); + jest.spyOn(instance, 'selectRows') .mockReturnValue(Promise.reject(new Error('Error')) as never); const callbacks = createCallbacks(); @@ -452,6 +474,9 @@ describe('selectByIndexesCommand', () => { it('returns failure when deselectRows rejects', async () => { const instance = await createGrid(); + jest.spyOn(instance.getDataSource().store(), 'load').mockReturnValue( + Promise.resolve([{ id: 1, name: 'Alpha' }]) as never, + ); jest.spyOn(instance, 'deselectRows') .mockReturnValue(Promise.reject(new Error('Error')) as never); const callbacks = createCallbacks(); @@ -465,20 +490,26 @@ describe('selectByIndexesCommand', () => { }); 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.getDataSource().store(), 'load').mockReturnValue( + Promise.resolve([{ id: 1, name: 'Alpha' }, { id: 3, name: 'Gamma' }]) as never, + ); + jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); await selectByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 3], mode: 'select', }); - 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.getDataSource().store(), 'load').mockReturnValue( + Promise.resolve([{ id: 1, name: 'Alpha' }]) as never, + ); jest.spyOn(instance, 'deselectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); @@ -486,7 +517,7 @@ describe('selectByIndexesCommand', () => { indexes: [1], mode: 'deselect', }); - 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 () => { @@ -497,7 +528,7 @@ describe('selectByIndexesCommand', () => { indexes: [1], mode: 'select', }); - expect(callbacks.failure).toHaveBeenCalledWith('Select row(s) number 1 on the current page.'); + expect(callbacks.failure).toHaveBeenCalledWith('Select row(s) number 1.'); }); }); }); 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..876a8bab0d9f 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, + splitIntoContiguousRanges, } from '../utils'; describe('normalizeKey', () => { @@ -168,3 +172,37 @@ describe('resolveFilterValue', () => { expect(resolveFilterValue('date', true)).toBe(true); }); }); + +describe('splitIntoContiguousRanges', () => { + it('returns an empty array for an empty input', () => { + expect(splitIntoContiguousRanges([])).toEqual([]); + }); + + it('wraps a single index into a single range', () => { + expect(splitIntoContiguousRanges([5])).toEqual([[5]]); + }); + + it('keeps a fully contiguous input as one range', () => { + expect(splitIntoContiguousRanges([1, 2, 3, 4])).toEqual([[1, 2, 3, 4]]); + }); + + it('splits non-contiguous indexes into multiple ranges', () => { + expect(splitIntoContiguousRanges([1, 2, 5, 6, 7, 10])).toEqual([ + [1, 2], [5, 6, 7], [10], + ]); + }); + + it('sorts unsorted input before splitting', () => { + expect(splitIntoContiguousRanges([5, 1, 6, 2, 10, 7])).toEqual([ + [1, 2], [5, 6, 7], [10], + ]); + }); + + it('deduplicates repeated indexes', () => { + expect(splitIntoContiguousRanges([1, 1, 2, 2, 3])).toEqual([[1, 2, 3]]); + }); + + it('treats a one-step gap as a boundary', () => { + expect(splitIntoContiguousRanges([1, 3])).toEqual([[1], [3]]); + }); +}); 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..96f657f47c28 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 @@ -3,7 +3,10 @@ import { z } from 'zod'; import { defineGridCommand } from './defineGridCommand'; import { - compositeKeyPairSchema, isKeyShapeValid, normalizeKey, + compositeKeyPairSchema, + isKeyShapeValid, + normalizeKey, + splitIntoContiguousRanges, } from './utils'; const selectByKeysCommandSchema = z.object({ @@ -52,44 +55,58 @@ const selectByIndexesCommandSchema = z.object({ 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.', + description: 'Select or deselect rows by their 1-based indexes within the currently filtered and sorted dataset. ' + + 'Index 1 is the first row of the dataset (NOT the first row on the visible page); indexes are NOT limited to the current page — any index up to the total row count is addressable, regardless of pageIndex/pageSize. ' + + 'Use this command for a SINGLE contiguous range per call. When the user asks for several non-contiguous ranges (e.g. "select rows 1 to 50 and 70 to 100"), invoke this command separately for EACH range — once with indexes [1, 2, ..., 50] and once with indexes [70, 71, ..., 100]. ' + + '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: selectByIndexesCommandSchema, 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 defaultMessage = `${action} row(s) number ${rowIndexes}.`; 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', - ); + const dataSource = component.getDataSource(); + const store = dataSource?.store(); - if (!allIndexesValid) { + if (!dataSource || !store) { return failure(defaultMessage); } + const ranges = splitIntoContiguousRanges(args.indexes); + 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); + const baseLoadOptions = { ...dataSource.loadOptions() }; + + const loadedRanges = await Promise.all(ranges.map((range) => { + const skip = range[0] - 1; + const take = range.length; + return store.load({ ...baseLoadOptions, skip, take }) + .then((result) => { + const rows = Array.isArray(result) ? result : (result as { data: unknown[] }).data; + return { rows, take }; + }); + })); + + const allRowsResolved = loadedRanges.every( + ({ rows, take }) => Array.isArray(rows) && rows.length >= take, + ); + + if (!allRowsResolved) { + return failure(defaultMessage); + } + + const keys = loadedRanges.flatMap(({ rows }) => rows.map((row) => store.keyOf(row))); + + 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..50ba3595c224 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 splitIntoContiguousRanges = (indexes: number[]): number[][] => { + const sorted = [...new Set(indexes)].sort((a, b) => a - b); + const ranges: number[][] = []; + let current: number[] = []; + + sorted.forEach((value) => { + if (current.length === 0 || value === current[current.length - 1] + 1) { + current.push(value); + } else { + ranges.push(current); + current = [value]; + } + }); + + if (current.length > 0) { + ranges.push(current); + } + + return ranges; +}; + type FilterExprValue = BasicFilterExpr['value']; export function resolveFilterValue( From 7622162de0d435466e95387481a11ed96e6814a2 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Fri, 5 Jun 2026 16:02:21 +0400 Subject: [PATCH 2/8] add selection scope to selectByIndexes command and decompose the command --- .../commands/__tests__/selection.test.ts | 145 +++++++++++++++--- .../ai_assistant/commands/selection.ts | 96 ++++++++---- packages/devextreme/js/common/grids.d.ts | 1 + packages/devextreme/ts/dx.all.d.ts | 1 + 4 files changed, 191 insertions(+), 52 deletions(-) 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 ca8b0183df12..bddace65037c 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 @@ -273,53 +273,55 @@ describe('selectByIndexesCommand', () => { describe('schema', () => { it('accepts an array of positive integers with mode deselect', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1, 2, 3], mode: 'deselect', + indexes: [1, 2, 3], mode: 'deselect', scope: 'dataset', }).success).toBe(true); }); it('accepts mode select', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1], mode: 'select', + indexes: [1], mode: 'select', scope: 'dataset', }).success).toBe(true); }); it('rejects when indexes is missing', () => { - expect(selectByIndexesCommand.schema.safeParse({ mode: 'select' }).success).toBe(false); + expect(selectByIndexesCommand.schema.safeParse({ + mode: 'select', scope: 'dataset', + }).success).toBe(false); }); it('rejects when mode is missing', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1], + indexes: [1], scope: 'dataset', }).success).toBe(false); }); it('rejects an invalid mode value', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1], mode: 'toggle', + indexes: [1], mode: 'toggle', scope: 'dataset', }).success).toBe(false); }); it('rejects when indexes is an empty array', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [], mode: 'select', + indexes: [], mode: 'select', scope: 'dataset', }).success).toBe(false); }); it('rejects zero (indexes are 1-based)', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [0], mode: 'select', + indexes: [0], mode: 'select', scope: 'dataset', }).success).toBe(false); }); it('rejects negative indexes', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [-1], mode: 'select', + indexes: [-1], mode: 'select', scope: 'dataset', }).success).toBe(false); }); it('rejects non-integer indexes', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1.5], mode: 'select', + indexes: [1.5], mode: 'select', scope: 'dataset', }).success).toBe(false); }); @@ -327,9 +329,34 @@ describe('selectByIndexesCommand', () => { expect(selectByIndexesCommand.schema.safeParse({ indexes: [1], mode: 'select', + scope: 'dataset', extra: 1, }).success).toBe(false); }); + + it('rejects when scope is missing', () => { + expect(selectByIndexesCommand.schema.safeParse({ + indexes: [1], mode: 'select', + }).success).toBe(false); + }); + + it('accepts scope "dataset"', () => { + expect(selectByIndexesCommand.schema.safeParse({ + indexes: [1], mode: 'select', scope: 'dataset', + }).success).toBe(true); + }); + + it('accepts scope "page"', () => { + expect(selectByIndexesCommand.schema.safeParse({ + indexes: [1], mode: 'select', scope: 'page', + }).success).toBe(true); + }); + + it('rejects an invalid scope value', () => { + expect(selectByIndexesCommand.schema.safeParse({ + indexes: [1], mode: 'select', scope: 'global', + }).success).toBe(false); + }); }); describe('execute', () => { @@ -339,7 +366,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', + indexes: [1], mode: 'select', scope: 'dataset', }); expect(result.status).toBe('failure'); @@ -353,7 +380,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', + indexes: [1], mode: 'select', scope: 'dataset', }); expect(result.status).toBe('failure'); @@ -370,7 +397,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [2, 3], mode: 'select', + indexes: [2, 3], mode: 'select', scope: 'dataset', }); expect(loadSpy).toHaveBeenCalledTimes(1); @@ -398,7 +425,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 2, 5], mode: 'select', + indexes: [1, 2, 5], mode: 'select', scope: 'dataset', }); expect(loadSpy).toHaveBeenCalledTimes(2); @@ -417,7 +444,7 @@ describe('selectByIndexesCommand', () => { // Three rows in createGrid; range 1..10 cannot be fully resolved. const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], mode: 'select', + indexes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], mode: 'select', scope: 'dataset', }); expect(result.status).toBe('failure'); @@ -435,7 +462,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'deselect', + indexes: [1], mode: 'deselect', scope: 'dataset', }); expect(deselectSpy).toHaveBeenCalledWith([1]); @@ -450,7 +477,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', + indexes: [1], mode: 'select', scope: 'dataset', }); expect(result.status).toBe('failure'); @@ -466,7 +493,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', + indexes: [1], mode: 'select', scope: 'dataset', }); expect(result.status).toBe('failure'); @@ -482,11 +509,75 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'deselect', + indexes: [1], mode: 'deselect', scope: 'dataset', }); expect(result.status).toBe('failure'); }); + + 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 selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 3], mode: 'select', scope: 'page', + }); + + expect(loadSpy).not.toHaveBeenCalled(); + expect(selectSpy).toHaveBeenCalledWith([1, 3], true); + expect(result.status).toBe('success'); + }); + + 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 selectByIndexesCommand.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 selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 100], mode: 'select', scope: 'page', + }); + + expect(result.status).toBe('failure'); + 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 }, + ], + }); + const selectSpy = jest.spyOn(instance, 'selectRows'); + const callbacks = createCallbacks(); + + const result = await selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'page', + }); + + expect(result.status).toBe('failure'); + expect(selectSpy).not.toHaveBeenCalled(); + }); + }); }); describe('default message', () => { @@ -499,7 +590,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 3], mode: 'select', + indexes: [1, 3], mode: 'select', scope: 'dataset', }); expect(callbacks.success).toHaveBeenCalledWith('Select row(s) number 1, 3.'); @@ -514,7 +605,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'deselect', + indexes: [1], mode: 'deselect', scope: 'dataset', }); expect(callbacks.success).toHaveBeenCalledWith('Deselect row(s) number 1.'); @@ -525,11 +616,23 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', + indexes: [1], mode: 'select', scope: 'dataset', }); 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 selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 2], mode: 'select', scope: '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/selection.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts index 96f657f47c28..3c3f114dc296 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,4 +1,5 @@ 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'; @@ -51,12 +52,67 @@ export const selectByKeysCommand = defineGridCommand({ const selectByIndexesCommandSchema = z.object({ indexes: z.array(z.number().int().min(1)).min(1), mode: z.enum(['select', 'deselect']), + scope: z.enum(['dataset', 'page']), }).strict(); +const resolveKeysFromCurrentPage = ( + component: InternalGrid, + indexes: number[], +): RowKey[] | null => { + const items = component.getController('data').items(); + const normalizedRowIndexes = indexes.map((index) => index - 1); + const allIndexesValid = normalizedRowIndexes.every( + (index) => items[index]?.rowType === 'data', + ); + + if (!allIndexesValid) { + return null; + } + + return normalizedRowIndexes.map((index) => items[index].key); +}; + +const resolveKeysFromDataset = async ( + component: InternalGrid, + indexes: number[], +): Promise => { + const dataSource = component.getDataSource(); + const store = dataSource?.store(); + + if (!dataSource || !store) { + return null; + } + + const ranges = splitIntoContiguousRanges(indexes); + const baseLoadOptions = { ...dataSource.loadOptions() }; + + const loadedRanges = await Promise.all(ranges.map((range) => { + const skip = range[0] - 1; + const take = range.length; + return store.load({ ...baseLoadOptions, skip, take }) + .then((result) => { + const rows = Array.isArray(result) ? result : (result as { data: unknown[] }).data; + return { rows, take }; + }); + })); + + const allRowsResolved = loadedRanges.every( + ({ rows, take }) => Array.isArray(rows) && rows.length >= take, + ); + + if (!allRowsResolved) { + return null; + } + + return loadedRanges.flatMap(({ rows }) => rows.map((row) => store.keyOf(row))); +}; + export const selectByIndexesCommand = defineGridCommand({ name: 'selectByIndexes', - description: 'Select or deselect rows by their 1-based indexes within the currently filtered and sorted dataset. ' - + 'Index 1 is the first row of the dataset (NOT the first row on the visible page); indexes are NOT limited to the current page — any index up to the total row count is addressable, regardless of pageIndex/pageSize. ' + description: 'Select or deselect rows by their 1-based indexes. ' + + 'Always set scope to choose how indexes are interpreted: ' + + '"dataset" — 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 row on the visible page and group/header rows are not addressable. 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"). ' + 'Use this command for a SINGLE contiguous range per call. When the user asks for several non-contiguous ranges (e.g. "select rows 1 to 50 and 70 to 100"), invoke this command separately for EACH range — once with indexes [1, 2, ..., 50] and once with indexes [70, 71, ..., 100]. ' + '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. ' @@ -65,44 +121,22 @@ export const selectByIndexesCommand = defineGridCommand({ 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}.`; + 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 dataSource = component.getDataSource(); - const store = dataSource?.store(); - - if (!dataSource || !store) { - return failure(defaultMessage); - } - - const ranges = splitIntoContiguousRanges(args.indexes); - try { - const baseLoadOptions = { ...dataSource.loadOptions() }; - - const loadedRanges = await Promise.all(ranges.map((range) => { - const skip = range[0] - 1; - const take = range.length; - return store.load({ ...baseLoadOptions, skip, take }) - .then((result) => { - const rows = Array.isArray(result) ? result : (result as { data: unknown[] }).data; - return { rows, take }; - }); - })); - - const allRowsResolved = loadedRanges.every( - ({ rows, take }) => Array.isArray(rows) && rows.length >= take, - ); - - if (!allRowsResolved) { + const keys = args.scope === 'page' + ? resolveKeysFromCurrentPage(component, args.indexes) + : await resolveKeysFromDataset(component, args.indexes); + + if (keys === null) { return failure(defaultMessage); } - const keys = loadedRanges.flatMap(({ rows }) => rows.map((row) => store.keyOf(row))); - if (args.mode === 'deselect') { await component.deselectRows(keys); } else { diff --git a/packages/devextreme/js/common/grids.d.ts b/packages/devextreme/js/common/grids.d.ts index 72d53ff536c5..ccd939f79245 100644 --- a/packages/devextreme/js/common/grids.d.ts +++ b/packages/devextreme/js/common/grids.d.ts @@ -223,6 +223,7 @@ export type PredefinedCommands = { selectByIndexes: { indexes: number[]; mode: 'select' | 'deselect'; + scope: 'dataset' | 'page'; }; selectAll: {}; deselectAll: {}; diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index be00d6be8554..d1905b629e99 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -6612,6 +6612,7 @@ declare module DevExpress.common.grids { selectByIndexes: { indexes: number[]; mode: 'select' | 'deselect'; + scope: 'dataset' | 'page'; }; selectAll: {}; deselectAll: {}; From 59702ffbbba008b49e83942ee4fc340808109b67 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Fri, 5 Jun 2026 16:35:09 +0400 Subject: [PATCH 3/8] show failure message if keyExpr is not provided --- .../commands/__tests__/selection.test.ts | 24 +++++++++++++++++++ .../ai_assistant/commands/selection.ts | 6 +++++ 2 files changed, 30 insertions(+) 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 bddace65037c..2b3a97a09131 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 @@ -387,6 +387,30 @@ describe('selectByIndexesCommand', () => { expect(selectSpy).not.toHaveBeenCalled(); }); + it('returns failure (without loading) when no key is configured in dataset scope', async () => { + const instance = await createGrid(); + 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 selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 2], mode: 'select', scope: 'dataset', + }); + + expect(result.status).toBe('failure'); + expect(loadSpy).not.toHaveBeenCalled(); + expect(selectSpy).not.toHaveBeenCalled(); + }); + it('loads a contiguous range via store.load with skip/take and selects resolved keys with preserve=true', async () => { const instance = await createGrid(); const store = instance.getDataSource().store(); 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 3c3f114dc296..c0c1c41c9fb7 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 @@ -83,6 +83,12 @@ const resolveKeysFromDataset = async ( return null; } + const keyExpr = component.option('keyExpr') ?? store.key(); + + if (!keyExpr) { + return null; + } + const ranges = splitIntoContiguousRanges(indexes); const baseLoadOptions = { ...dataSource.loadOptions() }; From 9e4d9613eaa50873706d7f157027006177e3dde1 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Fri, 5 Jun 2026 17:27:00 +0400 Subject: [PATCH 4/8] rename "dataset" scope mode to "allPages" --- .../commands/__tests__/selection.test.ts | 52 +++++++++---------- .../ai_assistant/commands/selection.ts | 8 +-- packages/devextreme/js/common/grids.d.ts | 2 +- packages/devextreme/ts/dx.all.d.ts | 2 +- 4 files changed, 32 insertions(+), 32 deletions(-) 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 2b3a97a09131..d25a2ffc7594 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 @@ -273,55 +273,55 @@ describe('selectByIndexesCommand', () => { describe('schema', () => { it('accepts an array of positive integers with mode deselect', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1, 2, 3], mode: 'deselect', scope: 'dataset', + indexes: [1, 2, 3], mode: 'deselect', scope: 'allPages', }).success).toBe(true); }); it('accepts mode select', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1], mode: 'select', scope: 'dataset', + indexes: [1], mode: 'select', scope: 'allPages', }).success).toBe(true); }); it('rejects when indexes is missing', () => { expect(selectByIndexesCommand.schema.safeParse({ - mode: 'select', scope: 'dataset', + mode: 'select', scope: 'allPages', }).success).toBe(false); }); it('rejects when mode is missing', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1], scope: 'dataset', + indexes: [1], scope: 'allPages', }).success).toBe(false); }); it('rejects an invalid mode value', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1], mode: 'toggle', scope: 'dataset', + indexes: [1], mode: 'toggle', scope: 'allPages', }).success).toBe(false); }); it('rejects when indexes is an empty array', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [], mode: 'select', scope: 'dataset', + indexes: [], mode: 'select', scope: 'allPages', }).success).toBe(false); }); it('rejects zero (indexes are 1-based)', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [0], mode: 'select', scope: 'dataset', + indexes: [0], mode: 'select', scope: 'allPages', }).success).toBe(false); }); it('rejects negative indexes', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [-1], mode: 'select', scope: 'dataset', + indexes: [-1], mode: 'select', scope: 'allPages', }).success).toBe(false); }); it('rejects non-integer indexes', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1.5], mode: 'select', scope: 'dataset', + indexes: [1.5], mode: 'select', scope: 'allPages', }).success).toBe(false); }); @@ -329,7 +329,7 @@ describe('selectByIndexesCommand', () => { expect(selectByIndexesCommand.schema.safeParse({ indexes: [1], mode: 'select', - scope: 'dataset', + scope: 'allPages', extra: 1, }).success).toBe(false); }); @@ -340,9 +340,9 @@ describe('selectByIndexesCommand', () => { }).success).toBe(false); }); - it('accepts scope "dataset"', () => { + it('accepts scope "allPages"', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [1], mode: 'select', scope: 'dataset', + indexes: [1], mode: 'select', scope: 'allPages', }).success).toBe(true); }); @@ -366,7 +366,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', scope: 'dataset', + indexes: [1], mode: 'select', scope: 'allPages', }); expect(result.status).toBe('failure'); @@ -380,14 +380,14 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', scope: 'dataset', + indexes: [1], mode: 'select', scope: 'allPages', }); expect(result.status).toBe('failure'); expect(selectSpy).not.toHaveBeenCalled(); }); - it('returns failure (without loading) when no key is configured in dataset scope', async () => { + it('returns failure (without loading) when no key is configured in allPages scope', async () => { const instance = await createGrid(); const realOption = instance.option.bind(instance); jest.spyOn(instance, 'option').mockImplementation(((...callArgs: unknown[]): unknown => { @@ -403,7 +403,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 2], mode: 'select', scope: 'dataset', + indexes: [1, 2], mode: 'select', scope: 'allPages', }); expect(result.status).toBe('failure'); @@ -421,7 +421,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [2, 3], mode: 'select', scope: 'dataset', + indexes: [2, 3], mode: 'select', scope: 'allPages', }); expect(loadSpy).toHaveBeenCalledTimes(1); @@ -449,7 +449,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 2, 5], mode: 'select', scope: 'dataset', + indexes: [1, 2, 5], mode: 'select', scope: 'allPages', }); expect(loadSpy).toHaveBeenCalledTimes(2); @@ -468,7 +468,7 @@ describe('selectByIndexesCommand', () => { // Three rows in createGrid; range 1..10 cannot be fully resolved. const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], mode: 'select', scope: 'dataset', + indexes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], mode: 'select', scope: 'allPages', }); expect(result.status).toBe('failure'); @@ -486,7 +486,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'deselect', scope: 'dataset', + indexes: [1], mode: 'deselect', scope: 'allPages', }); expect(deselectSpy).toHaveBeenCalledWith([1]); @@ -501,7 +501,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', scope: 'dataset', + indexes: [1], mode: 'select', scope: 'allPages', }); expect(result.status).toBe('failure'); @@ -517,7 +517,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', scope: 'dataset', + indexes: [1], mode: 'select', scope: 'allPages', }); expect(result.status).toBe('failure'); @@ -533,7 +533,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'deselect', scope: 'dataset', + indexes: [1], mode: 'deselect', scope: 'allPages', }); expect(result.status).toBe('failure'); @@ -614,7 +614,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 3], mode: 'select', scope: 'dataset', + indexes: [1, 3], mode: 'select', scope: 'allPages', }); expect(callbacks.success).toHaveBeenCalledWith('Select row(s) number 1, 3.'); @@ -629,7 +629,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'deselect', scope: 'dataset', + indexes: [1], mode: 'deselect', scope: 'allPages', }); expect(callbacks.success).toHaveBeenCalledWith('Deselect row(s) number 1.'); @@ -640,7 +640,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', scope: 'dataset', + indexes: [1], mode: 'select', scope: 'allPages', }); expect(callbacks.failure).toHaveBeenCalledWith('Select row(s) number 1.'); 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 c0c1c41c9fb7..a5179b9d0e54 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 @@ -52,7 +52,7 @@ export const selectByKeysCommand = defineGridCommand({ const selectByIndexesCommandSchema = z.object({ indexes: z.array(z.number().int().min(1)).min(1), mode: z.enum(['select', 'deselect']), - scope: z.enum(['dataset', 'page']), + scope: z.enum(['allPages', 'page']), }).strict(); const resolveKeysFromCurrentPage = ( @@ -72,7 +72,7 @@ const resolveKeysFromCurrentPage = ( return normalizedRowIndexes.map((index) => items[index].key); }; -const resolveKeysFromDataset = async ( +const resolveKeysFromAllPages = async ( component: InternalGrid, indexes: number[], ): Promise => { @@ -117,7 +117,7 @@ export const selectByIndexesCommand = defineGridCommand({ name: 'selectByIndexes', description: 'Select or deselect rows by their 1-based indexes. ' + 'Always set scope to choose how indexes are interpreted: ' - + '"dataset" — 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"). ' + + '"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 row on the visible page and group/header rows are not addressable. 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"). ' + 'Use this command for a SINGLE contiguous range per call. When the user asks for several non-contiguous ranges (e.g. "select rows 1 to 50 and 70 to 100"), invoke this command separately for EACH range — once with indexes [1, 2, ..., 50] and once with indexes [70, 71, ..., 100]. ' + '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"). ' @@ -137,7 +137,7 @@ export const selectByIndexesCommand = defineGridCommand({ try { const keys = args.scope === 'page' ? resolveKeysFromCurrentPage(component, args.indexes) - : await resolveKeysFromDataset(component, args.indexes); + : await resolveKeysFromAllPages(component, args.indexes); if (keys === null) { return failure(defaultMessage); diff --git a/packages/devextreme/js/common/grids.d.ts b/packages/devextreme/js/common/grids.d.ts index ccd939f79245..90617c322d2d 100644 --- a/packages/devextreme/js/common/grids.d.ts +++ b/packages/devextreme/js/common/grids.d.ts @@ -223,7 +223,7 @@ export type PredefinedCommands = { selectByIndexes: { indexes: number[]; mode: 'select' | 'deselect'; - scope: 'dataset' | 'page'; + scope: 'page' | 'allPages'; }; selectAll: {}; deselectAll: {}; diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index d1905b629e99..5653f3614b81 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -6612,7 +6612,7 @@ declare module DevExpress.common.grids { selectByIndexes: { indexes: number[]; mode: 'select' | 'deselect'; - scope: 'dataset' | 'page'; + scope: 'page' | 'allPages'; }; selectAll: {}; deselectAll: {}; From 4b3069a4712ff09aa15f91e8f3c432cdc0f6aff1 Mon Sep 17 00:00:00 2001 From: "anna.shakhova" Date: Tue, 9 Jun 2026 13:37:22 +0200 Subject: [PATCH 5/8] AI Assistant: selectByIndexes - support remote and local operations --- .../commands/__tests__/selection.test.ts | 476 ++++++++++++------ .../commands/__tests__/utils.test.ts | 30 +- .../ai_assistant/commands/selection.ts | 103 +++- .../grid_core/ai_assistant/commands/utils.ts | 26 +- 4 files changed, 442 insertions(+), 193 deletions(-) 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 d25a2ffc7594..d6cbb1fd559c 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 @@ -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()); @@ -373,170 +391,350 @@ describe('selectByIndexesCommand', () => { expect(selectSpy).not.toHaveBeenCalled(); }); - it('returns failure when dataSource/store is missing', async () => { - const instance = await createGrid(); - jest.spyOn(instance, 'getDataSource').mockReturnValue(undefined as never); - const selectSpy = jest.spyOn(instance, 'selectRows'); - 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 selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', scope: 'allPages', + const result = await selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', + }); + + 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(); - it('returns failure (without loading) when no key is configured in allPages scope', async () => { - const instance = await createGrid(); - 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 selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 2], mode: 'select', scope: 'allPages', + }); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 2], mode: 'select', scope: 'allPages', + expect(result.status).toBe('failure'); + expect(loadSpy).not.toHaveBeenCalled(); + expect(selectSpy).not.toHaveBeenCalled(); }); - expect(result.status).toBe('failure'); - expect(loadSpy).not.toHaveBeenCalled(); - expect(selectSpy).not.toHaveBeenCalled(); - }); + 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(); - it('loads a contiguous range via store.load with skip/take and selects resolved keys with preserve=true', async () => { - const instance = await createGrid(); - 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 selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [2, 3], mode: 'select', scope: 'allPages', + }); - const result = await selectByIndexesCommand.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'); }); - 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'); - }); + 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(); - it('splits non-contiguous indexes into multiple store.load calls and aggregates keys', async () => { - const instance = await createGrid(); - const store = instance.getDataSource().store(); - const loadSpy = jest.spyOn(store, 'load').mockImplementation((options: unknown) => { - const { skip, take } = options as { skip: number; take: number }; - if (skip === 0 && take === 2) { - return Promise.resolve([{ id: 1, name: 'Alpha' }, { id: 2, name: 'Beta' }]) as never; - } - if (skip === 4 && take === 1) { - return Promise.resolve([{ id: 5, name: 'Epsilon' }]) as never; - } - return Promise.resolve([]) as never; + await selectByIndexesCommand.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'); }); - const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); - const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 2, 5], mode: 'select', scope: 'allPages', + 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 selectByIndexesCommand.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'); }); - expect(loadSpy).toHaveBeenCalledTimes(2); - expect(selectSpy).toHaveBeenCalledWith([1, 2, 5], true); - expect(result.status).toBe('success'); - }); + 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(); - it('returns failure when store.load returns fewer rows than requested (range exceeds dataset)', async () => { - const instance = await createGrid(); - 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(); + const result = await selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 5000], mode: 'select', scope: 'allPages', + }); - // Three rows in createGrid; range 1..10 cannot be fully resolved. - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], mode: 'select', scope: 'allPages', + expect(loadSpy).toHaveBeenCalledTimes(2); + expect(selectSpy).toHaveBeenCalledWith([1, 99], true); + expect(result.status).toBe('success'); }); - expect(result.status).toBe('failure'); - expect(selectSpy).not.toHaveBeenCalled(); - }); + 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(); - it('resolves keys via store.load and calls deselectRows when deselecting', async () => { - const instance = await createGrid(); - 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 selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', + }); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'deselect', scope: 'allPages', + expect(result.status).toBe('failure'); + expect(loadSpy).not.toHaveBeenCalled(); + expect(selectSpy).not.toHaveBeenCalled(); }); - expect(deselectSpy).toHaveBeenCalledWith([1]); - expect(selectSpy).not.toHaveBeenCalled(); - 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(); - it('returns failure when store.load rejects', async () => { - const instance = await createGrid(); - jest.spyOn(instance.getDataSource().store(), 'load') - .mockReturnValue(Promise.reject(new Error('Error')) as never); - const callbacks = createCallbacks(); + // Three rows in createGrid; range 1..10 cannot be fully resolved. + const result = await selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], mode: 'select', scope: 'allPages', + }); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', scope: 'allPages', + expect(result.status).toBe('failure'); + expect(selectSpy).not.toHaveBeenCalled(); }); - expect(result.status).toBe('failure'); - }); + 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(); - it('returns failure when selectRows rejects', async () => { - const instance = await createGrid(); - jest.spyOn(instance.getDataSource().store(), 'load').mockReturnValue( - Promise.resolve([{ id: 1, name: 'Alpha' }]) as never, - ); - jest.spyOn(instance, 'selectRows') - .mockReturnValue(Promise.reject(new Error('Error')) as never); - const callbacks = createCallbacks(); + const result = await selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'deselect', scope: 'allPages', + }); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'select', scope: 'allPages', + expect(deselectSpy).toHaveBeenCalledWith([1]); + expect(selectSpy).not.toHaveBeenCalled(); + expect(result.status).toBe('success'); }); - expect(result.status).toBe('failure'); + it('returns failure when store.load rejects', async () => { + const instance = await createRemoteGrid(); + jest.spyOn(instance.getDataSource().store(), 'load') + .mockReturnValue(Promise.reject(new Error('Error')) as never); + const callbacks = createCallbacks(); + + const result = await selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', + }); + + expect(result.status).toBe('failure'); + }); + + 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') + .mockReturnValue(Promise.reject(new Error('Error')) as never); + const callbacks = createCallbacks(); + + const result = await selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', + }); + + expect(result.status).toBe('failure'); + }); + + 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') + .mockReturnValue(Promise.reject(new Error('Error')) as never); + const callbacks = createCallbacks(); + + const result = await selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'deselect', scope: 'allPages', + }); + + expect(result.status).toBe('failure'); + }); }); - it('returns failure when deselectRows rejects', async () => { - const instance = await createGrid(); - jest.spyOn(instance.getDataSource().store(), 'load').mockReturnValue( - Promise.resolve([{ id: 1, name: 'Alpha' }]) as never, - ); - jest.spyOn(instance, 'deselectRows') - .mockReturnValue(Promise.reject(new Error('Error')) as never); - const callbacks = createCallbacks(); + 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 selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [1], mode: 'deselect', scope: 'allPages', + const result = await executeWithTimers( + () => selectByIndexesCommand.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'); }); - expect(result.status).toBe('failure'); + 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( + () => selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [2], mode: 'deselect', scope: 'allPages', + }), + ); + + expect(deselectSpy).toHaveBeenCalledWith([2]); + expect(result.status).toBe('success'); + }); + + it('returns failure when an index exceeds the dataset', async () => { + const instance = await createGrid(); + const selectSpy = jest.spyOn(instance, 'selectRows'); + const callbacks = createCallbacks(); + + const result = await executeWithTimers( + () => selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1, 100], mode: 'select', scope: 'allPages', + }), + ); + + expect(result.status).toBe('failure'); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + 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( + () => selectByIndexesCommand.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( + () => selectByIndexesCommand.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') + .mockReturnValue(Promise.reject(new Error('Error')) as never); + const callbacks = createCallbacks(); + + const result = await executeWithTimers( + () => selectByIndexesCommand.execute(instance, callbacks)({ + indexes: [1], mode: 'select', scope: 'allPages', + }), + ); + + expect(result.status).toBe('failure'); + }); }); describe('scope "page"', () => { @@ -582,24 +780,22 @@ describe('selectByIndexesCommand', () => { 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 + 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'); + const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); const result = await selectByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'select', scope: 'page', }); - expect(result.status).toBe('failure'); - expect(selectSpy).not.toHaveBeenCalled(); + expect(selectSpy).toHaveBeenCalledWith([1], true); + expect(result.status).toBe('success'); }); }); }); @@ -607,30 +803,24 @@ describe('selectByIndexesCommand', () => { describe('default message', () => { it('reports the 1-based row numbers on select', async () => { const instance = await createGrid(); - jest.spyOn(instance.getDataSource().store(), 'load').mockReturnValue( - Promise.resolve([{ id: 1, name: 'Alpha' }, { id: 3, name: 'Gamma' }]) as never, - ); jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - await selectByIndexesCommand.execute(instance, callbacks)({ + await executeWithTimers(() => selectByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 3], mode: 'select', scope: 'allPages', - }); + })); expect(callbacks.success).toHaveBeenCalledWith('Select row(s) number 1, 3.'); }); it('reports the 1-based row numbers on deselect', async () => { const instance = await createGrid(); - jest.spyOn(instance.getDataSource().store(), 'load').mockReturnValue( - Promise.resolve([{ id: 1, name: 'Alpha' }]) as never, - ); jest.spyOn(instance, 'deselectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - await selectByIndexesCommand.execute(instance, callbacks)({ + await executeWithTimers(() => selectByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'deselect', scope: 'allPages', - }); + })); expect(callbacks.success).toHaveBeenCalledWith('Deselect row(s) number 1.'); }); 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 876a8bab0d9f..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 @@ -11,7 +11,7 @@ import { // eslint-disable-next-line spellcheck/spell-checker optionalNullish, resolveFilterValue, - splitIntoContiguousRanges, + splitIntoLoadWindows, } from '../utils'; describe('normalizeKey', () => { @@ -173,36 +173,32 @@ describe('resolveFilterValue', () => { }); }); -describe('splitIntoContiguousRanges', () => { +describe('splitIntoLoadWindows', () => { it('returns an empty array for an empty input', () => { - expect(splitIntoContiguousRanges([])).toEqual([]); + expect(splitIntoLoadWindows([], 10)).toEqual([]); }); - it('wraps a single index into a single range', () => { - expect(splitIntoContiguousRanges([5])).toEqual([[5]]); + it('wraps a single index into a single window', () => { + expect(splitIntoLoadWindows([5], 10)).toEqual([[5]]); }); - it('keeps a fully contiguous input as one range', () => { - expect(splitIntoContiguousRanges([1, 2, 3, 4])).toEqual([[1, 2, 3, 4]]); + it('merges across gaps while the span stays within the window', () => { + expect(splitIntoLoadWindows([1, 3, 5], 10)).toEqual([[1, 3, 5]]); }); - it('splits non-contiguous indexes into multiple ranges', () => { - expect(splitIntoContiguousRanges([1, 2, 5, 6, 7, 10])).toEqual([ - [1, 2], [5, 6, 7], [10], + 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 splitting', () => { - expect(splitIntoContiguousRanges([5, 1, 6, 2, 10, 7])).toEqual([ + 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(splitIntoContiguousRanges([1, 1, 2, 2, 3])).toEqual([[1, 2, 3]]); - }); - - it('treats a one-step gap as a boundary', () => { - expect(splitIntoContiguousRanges([1, 3])).toEqual([[1], [3]]); + 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/selection.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts index a5179b9d0e54..e8b36be07e96 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,4 +1,5 @@ import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import type { Item } from '@ts/grids/grid_core/data_controller/m_data_controller'; import type { InternalGrid, RowKey } from '@ts/grids/grid_core/m_types'; import { z } from 'zod'; @@ -7,9 +8,11 @@ import { compositeKeyPairSchema, isKeyShapeValid, normalizeKey, - splitIntoContiguousRanges, + splitIntoLoadWindows, } from './utils'; +const MAX_LOAD_WINDOW_PAGES = 5; + const selectByKeysCommandSchema = z.object({ keys: z.array(z.union([ z.string(), @@ -55,24 +58,34 @@ const selectByIndexesCommandSchema = z.object({ scope: z.enum(['allPages', 'page']), }).strict(); -const resolveKeysFromCurrentPage = ( - component: InternalGrid, +// Maps 1-based indexes to row keys. Group/footer rows are not counted, so an +// index addresses the Nth data row; an index past the last data row rejects the whole set +const resolveKeysFromItems = ( + items: Item[], indexes: number[], ): RowKey[] | null => { - const items = component.getController('data').items(); + const dataItems = items.filter((item) => item.rowType === 'data'); const normalizedRowIndexes = indexes.map((index) => index - 1); const allIndexesValid = normalizedRowIndexes.every( - (index) => items[index]?.rowType === 'data', + (index) => dataItems[index] !== undefined, ); if (!allIndexesValid) { return null; } - return normalizedRowIndexes.map((index) => items[index].key); + return normalizedRowIndexes.map((index) => dataItems[index].key); }; -const resolveKeysFromAllPages = async ( +const resolveKeysFromCurrentPage = ( + component: InternalGrid, + indexes: number[], +): RowKey[] | null => resolveKeysFromItems( + component.getController('data').items(), + indexes, +); + +const resolveKeysFromAllPagesRemote = async ( component: InternalGrid, indexes: number[], ): Promise => { @@ -89,28 +102,78 @@ const resolveKeysFromAllPages = async ( return null; } - const ranges = splitIntoContiguousRanges(indexes); - const baseLoadOptions = { ...dataSource.loadOptions() }; + // 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 loadedRanges = await Promise.all(ranges.map((range) => { - const skip = range[0] - 1; - const take = range.length; + 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 { rows, take }; + return { window, rows }; }); })); - const allRowsResolved = loadedRanges.every( - ({ rows, take }) => Array.isArray(rows) && rows.length >= take, - ); + const keys: RowKey[] = []; - if (!allRowsResolved) { - return null; + 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 loadedRanges.flatMap(({ rows }) => rows.map((row) => store.keyOf(row))); + return keys; +}; + +const resolveKeysFromAllPagesLocal = async ( + component: InternalGrid, + indexes: number[], +): Promise => { + const items = await (component.getController('data').loadAll(undefined) as unknown as Promise); + + return resolveKeysFromItems(items, 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 selectByIndexesCommand = defineGridCommand({ @@ -118,7 +181,7 @@ export const selectByIndexesCommand = defineGridCommand({ description: 'Select or deselect rows by their 1-based indexes. ' + '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 row on the visible page and group/header rows are not addressable. 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"). ' + + '"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"). ' + 'Use this command for a SINGLE contiguous range per call. When the user asks for several non-contiguous ranges (e.g. "select rows 1 to 50 and 70 to 100"), invoke this command separately for EACH range — once with indexes [1, 2, ..., 50] and once with indexes [70, 71, ..., 100]. ' + '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. ' 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 50ba3595c224..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,25 +67,25 @@ export const isKeyShapeValid = ( return keyExpr.every((field) => field in key); }; -export const splitIntoContiguousRanges = (indexes: number[]): number[][] => { +export const splitIntoLoadWindows = ( + indexes: number[], + maxWindowSize: number, +): number[][] => { const sorted = [...new Set(indexes)].sort((a, b) => a - b); - const ranges: number[][] = []; - let current: number[] = []; + const windows: number[][] = []; - sorted.forEach((value) => { - if (current.length === 0 || value === current[current.length - 1] + 1) { - current.push(value); + 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 { - ranges.push(current); - current = [value]; + windows.push([index]); } }); - if (current.length > 0) { - ranges.push(current); - } - - return ranges; + return windows; }; type FilterExprValue = BasicFilterExpr['value']; From fc8436f9fe24c355eed8b6f8d9d69b063945ce02 Mon Sep 17 00:00:00 2001 From: "anna.shakhova" Date: Tue, 9 Jun 2026 14:37:53 +0200 Subject: [PATCH 6/8] AI Assistant: support non-flat data in resolve key by index algorithm --- .../ai_assistant/commands/selection.ts | 30 ++++--- .../data_controller/m_data_controller.ts | 9 +- .../ai_assistant.ts | 0 .../commands/selection.integration.test.ts | 85 +++++++++++++++++++ .../data_controller/m_data_controller.ts | 13 +++ .../js/__internal/grids/tree_list/m_widget.ts | 2 +- 6 files changed, 123 insertions(+), 16 deletions(-) rename packages/devextreme/js/__internal/grids/tree_list/{module_not_extended => ai_assistant}/ai_assistant.ts (100%) create mode 100644 packages/devextreme/js/__internal/grids/tree_list/ai_assistant/commands/selection.integration.test.ts 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 e8b36be07e96..863c6817b6c3 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,5 +1,4 @@ import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; -import type { Item } from '@ts/grids/grid_core/data_controller/m_data_controller'; import type { InternalGrid, RowKey } from '@ts/grids/grid_core/m_types'; import { z } from 'zod'; @@ -58,32 +57,35 @@ const selectByIndexesCommandSchema = z.object({ scope: z.enum(['allPages', 'page']), }).strict(); -// Maps 1-based indexes to row keys. Group/footer rows are not counted, so an -// index addresses the Nth data row; an index past the last data row rejects the whole set -const resolveKeysFromItems = ( - items: Item[], +// 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 dataItems = items.filter((item) => item.rowType === 'data'); const normalizedRowIndexes = indexes.map((index) => index - 1); const allIndexesValid = normalizedRowIndexes.every( - (index) => dataItems[index] !== undefined, + (index) => index < keys.length, ); if (!allIndexesValid) { return null; } - return normalizedRowIndexes.map((index) => dataItems[index].key); + return normalizedRowIndexes.map((index) => keys[index]); }; const resolveKeysFromCurrentPage = ( component: InternalGrid, indexes: number[], -): RowKey[] | null => resolveKeysFromItems( - component.getController('data').items(), - indexes, -); +): 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, @@ -156,9 +158,9 @@ const resolveKeysFromAllPagesLocal = async ( component: InternalGrid, indexes: number[], ): Promise => { - const items = await (component.getController('data').loadAll(undefined) as unknown as Promise); + const keys = await component.getController('data').getAllDataRowKeys(); - return resolveKeysFromItems(items, indexes); + return pickKeysByIndex(keys, indexes); }; // Picks the "allPages" strategy by paging mode: 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..0787d16cdc8f --- /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 { selectByIndexesCommand } 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('selectByIndexesCommand 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 selectByIndexesCommand.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 selectByIndexesCommand.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; From a9e5f35707cfd382037d70030984f037df80178f Mon Sep 17 00:00:00 2001 From: "anna.shakhova" Date: Tue, 9 Jun 2026 15:02:40 +0200 Subject: [PATCH 7/8] AI Assistant: rename selectByIndexes command --- .../commands/__tests__/selection.test.ts | 86 +++++++++---------- .../grid_core/ai_assistant/commands/index.ts | 4 +- .../ai_assistant/commands/selection.ts | 8 +- .../commands/selection.integration.test.ts | 8 +- packages/devextreme/js/common/grids.d.ts | 2 +- packages/devextreme/ts/dx.all.d.ts | 2 +- 6 files changed, 55 insertions(+), 55 deletions(-) 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 d6cbb1fd559c..c892df6c1556 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 = (): { @@ -284,67 +284,67 @@ 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({ + expect(selectionByIndexesCommand.schema.safeParse({ indexes: [1, 2, 3], mode: 'deselect', scope: 'allPages', }).success).toBe(true); }); it('accepts mode select', () => { - expect(selectByIndexesCommand.schema.safeParse({ + expect(selectionByIndexesCommand.schema.safeParse({ indexes: [1], mode: 'select', scope: 'allPages', }).success).toBe(true); }); it('rejects when indexes is missing', () => { - expect(selectByIndexesCommand.schema.safeParse({ + expect(selectionByIndexesCommand.schema.safeParse({ mode: 'select', scope: 'allPages', }).success).toBe(false); }); it('rejects when mode is missing', () => { - expect(selectByIndexesCommand.schema.safeParse({ + expect(selectionByIndexesCommand.schema.safeParse({ indexes: [1], scope: 'allPages', }).success).toBe(false); }); it('rejects an invalid mode value', () => { - expect(selectByIndexesCommand.schema.safeParse({ + 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({ + expect(selectionByIndexesCommand.schema.safeParse({ indexes: [], mode: 'select', scope: 'allPages', }).success).toBe(false); }); it('rejects zero (indexes are 1-based)', () => { - expect(selectByIndexesCommand.schema.safeParse({ + expect(selectionByIndexesCommand.schema.safeParse({ indexes: [0], mode: 'select', scope: 'allPages', }).success).toBe(false); }); it('rejects negative indexes', () => { - expect(selectByIndexesCommand.schema.safeParse({ + expect(selectionByIndexesCommand.schema.safeParse({ indexes: [-1], mode: 'select', scope: 'allPages', }).success).toBe(false); }); it('rejects non-integer indexes', () => { - expect(selectByIndexesCommand.schema.safeParse({ + 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', @@ -353,25 +353,25 @@ describe('selectByIndexesCommand', () => { }); it('rejects when scope is missing', () => { - expect(selectByIndexesCommand.schema.safeParse({ + expect(selectionByIndexesCommand.schema.safeParse({ indexes: [1], mode: 'select', }).success).toBe(false); }); it('accepts scope "allPages"', () => { - expect(selectByIndexesCommand.schema.safeParse({ + expect(selectionByIndexesCommand.schema.safeParse({ indexes: [1], mode: 'select', scope: 'allPages', }).success).toBe(true); }); it('accepts scope "page"', () => { - expect(selectByIndexesCommand.schema.safeParse({ + expect(selectionByIndexesCommand.schema.safeParse({ indexes: [1], mode: 'select', scope: 'page', }).success).toBe(true); }); it('rejects an invalid scope value', () => { - expect(selectByIndexesCommand.schema.safeParse({ + expect(selectionByIndexesCommand.schema.safeParse({ indexes: [1], mode: 'select', scope: 'global', }).success).toBe(false); }); @@ -383,7 +383,7 @@ describe('selectByIndexesCommand', () => { const selectSpy = jest.spyOn(instance, 'selectRows'); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'select', scope: 'allPages', }); @@ -398,7 +398,7 @@ describe('selectByIndexesCommand', () => { const selectSpy = jest.spyOn(instance, 'selectRows'); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'select', scope: 'allPages', }); @@ -421,7 +421,7 @@ describe('selectByIndexesCommand', () => { const selectSpy = jest.spyOn(instance, 'selectRows'); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 2], mode: 'select', scope: 'allPages', }); @@ -439,7 +439,7 @@ describe('selectByIndexesCommand', () => { const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [2, 3], mode: 'select', scope: 'allPages', }); @@ -468,7 +468,7 @@ describe('selectByIndexesCommand', () => { jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - await selectByIndexesCommand.execute(instance, callbacks)({ + await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'select', scope: 'allPages', }); @@ -491,7 +491,7 @@ describe('selectByIndexesCommand', () => { const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 2, 5], mode: 'select', scope: 'allPages', }); @@ -516,7 +516,7 @@ describe('selectByIndexesCommand', () => { const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 5000], mode: 'select', scope: 'allPages', }); @@ -536,7 +536,7 @@ describe('selectByIndexesCommand', () => { const selectSpy = jest.spyOn(instance, 'selectRows'); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'select', scope: 'allPages', }); @@ -555,7 +555,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); // Three rows in createGrid; range 1..10 cannot be fully resolved. - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], mode: 'select', scope: 'allPages', }); @@ -573,7 +573,7 @@ describe('selectByIndexesCommand', () => { const selectSpy = jest.spyOn(instance, 'selectRows'); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'deselect', scope: 'allPages', }); @@ -588,7 +588,7 @@ describe('selectByIndexesCommand', () => { .mockReturnValue(Promise.reject(new Error('Error')) as never); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'select', scope: 'allPages', }); @@ -604,7 +604,7 @@ describe('selectByIndexesCommand', () => { .mockReturnValue(Promise.reject(new Error('Error')) as never); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'select', scope: 'allPages', }); @@ -620,7 +620,7 @@ describe('selectByIndexesCommand', () => { .mockReturnValue(Promise.reject(new Error('Error')) as never); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'deselect', scope: 'allPages', }); @@ -637,7 +637,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await executeWithTimers( - () => selectByIndexesCommand.execute(instance, callbacks)({ + () => selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 3], mode: 'select', scope: 'allPages', }), ); @@ -654,7 +654,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await executeWithTimers( - () => selectByIndexesCommand.execute(instance, callbacks)({ + () => selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [2], mode: 'deselect', scope: 'allPages', }), ); @@ -669,7 +669,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await executeWithTimers( - () => selectByIndexesCommand.execute(instance, callbacks)({ + () => selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 100], mode: 'select', scope: 'allPages', }), ); @@ -689,7 +689,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await executeWithTimers( - () => selectByIndexesCommand.execute(instance, callbacks)({ + () => selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'select', scope: 'allPages', }), ); @@ -712,7 +712,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await executeWithTimers( - () => selectByIndexesCommand.execute(instance, callbacks)({ + () => selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'select', scope: 'allPages', }), ); @@ -728,7 +728,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); const result = await executeWithTimers( - () => selectByIndexesCommand.execute(instance, callbacks)({ + () => selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'select', scope: 'allPages', }), ); @@ -744,7 +744,7 @@ describe('selectByIndexesCommand', () => { const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 3], mode: 'select', scope: 'page', }); @@ -758,7 +758,7 @@ describe('selectByIndexesCommand', () => { const deselectSpy = jest.spyOn(instance, 'deselectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'deselect', scope: 'page', }); @@ -772,7 +772,7 @@ describe('selectByIndexesCommand', () => { const callbacks = createCallbacks(); // Three rows in createGrid; 1-based index 100 has no row on the current page. - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 100], mode: 'select', scope: 'page', }); @@ -790,7 +790,7 @@ describe('selectByIndexesCommand', () => { const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'select', scope: 'page', }); @@ -806,7 +806,7 @@ describe('selectByIndexesCommand', () => { jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - await executeWithTimers(() => selectByIndexesCommand.execute(instance, callbacks)({ + await executeWithTimers(() => selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 3], mode: 'select', scope: 'allPages', })); @@ -818,7 +818,7 @@ describe('selectByIndexesCommand', () => { jest.spyOn(instance, 'deselectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - await executeWithTimers(() => selectByIndexesCommand.execute(instance, callbacks)({ + await executeWithTimers(() => selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'deselect', scope: 'allPages', })); @@ -829,7 +829,7 @@ describe('selectByIndexesCommand', () => { const instance = await createGrid({ selection: { mode: 'none' } }); const callbacks = createCallbacks(); - await selectByIndexesCommand.execute(instance, callbacks)({ + await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1], mode: 'select', scope: 'allPages', }); @@ -841,7 +841,7 @@ describe('selectByIndexesCommand', () => { jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - await selectByIndexesCommand.execute(instance, callbacks)({ + await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 2], mode: 'select', scope: 'page', }); 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 863c6817b6c3..8c0942b4575e 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 @@ -51,7 +51,7 @@ 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']), @@ -178,8 +178,8 @@ const resolveKeysFromAllPages = ( : resolveKeysFromAllPagesLocal(component, indexes); }; -export const selectByIndexesCommand = defineGridCommand({ - name: 'selectByIndexes', +export const selectionByIndexesCommand = defineGridCommand({ + name: 'selectionByIndexes', description: 'Select or deselect rows by their 1-based indexes. ' + '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"). ' @@ -188,7 +188,7 @@ export const selectByIndexesCommand = defineGridCommand({ + '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: selectByIndexesCommandSchema, + schema: selectionByIndexesCommandSchema, execute: (component, { success, failure }) => async (args): Promise => { const rowIndexes = args.indexes.join(', '); const action = args.mode === 'deselect' ? 'Deselect' : 'Select'; 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 index 0787d16cdc8f..69d6f14ba955 100644 --- 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 @@ -4,7 +4,7 @@ import { import $ from '@js/core/renderer'; import type { Properties as TreeListProperties } from '@js/ui/tree_list'; import TreeList from '@js/ui/tree_list'; -import { selectByIndexesCommand } from '@ts/grids/grid_core/ai_assistant/commands/selection'; +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'; @@ -44,7 +44,7 @@ const createTreeList = ( resolve(instance as unknown as InternalGrid); }); -describe('selectByIndexesCommand on TreeList — "allPages" scope', () => { +describe('selectionByIndexesCommand on TreeList — "allPages" scope', () => { beforeEach(async () => jest.useFakeTimers()); afterEach(() => { @@ -61,7 +61,7 @@ describe('selectByIndexesCommand on TreeList — "allPages" scope', () => { const selectSpy = jest.spyOn(instance, 'selectRows').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 3], mode: 'select', scope: 'allPages', }); @@ -75,7 +75,7 @@ describe('selectByIndexesCommand on TreeList — "allPages" scope', () => { const selectSpy = jest.spyOn(instance, 'selectRows'); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ + const result = await selectionByIndexesCommand.execute(instance, callbacks)({ indexes: [99], mode: 'select', scope: 'allPages', }); diff --git a/packages/devextreme/js/common/grids.d.ts b/packages/devextreme/js/common/grids.d.ts index 90617c322d2d..9cdecac4833d 100644 --- a/packages/devextreme/js/common/grids.d.ts +++ b/packages/devextreme/js/common/grids.d.ts @@ -220,7 +220,7 @@ export type PredefinedCommands = { keys: Array>; preserve: boolean; }; - selectByIndexes: { + selectionByIndexes: { indexes: number[]; mode: 'select' | 'deselect'; scope: 'page' | 'allPages'; diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index 5653f3614b81..16a186a1c8f7 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -6609,7 +6609,7 @@ declare module DevExpress.common.grids { keys: Array>; preserve: boolean; }; - selectByIndexes: { + selectionByIndexes: { indexes: number[]; mode: 'select' | 'deselect'; scope: 'page' | 'allPages'; From 33f8a54573c201b81aca6fe710dc5f75df1935c8 Mon Sep 17 00:00:00 2001 From: "anna.shakhova" Date: Tue, 9 Jun 2026 16:09:32 +0200 Subject: [PATCH 8/8] AI Assistant: fix tests, adjust prompt --- .../ai_assistant/commands/__tests__/selection.test.ts | 10 +++++----- .../grids/grid_core/ai_assistant/commands/selection.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) 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 c892df6c1556..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 @@ -250,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)({ @@ -585,7 +585,7 @@ describe('selectionByIndexesCommand', () => { it('returns failure when store.load rejects', async () => { const instance = await createRemoteGrid(); jest.spyOn(instance.getDataSource().store(), 'load') - .mockReturnValue(Promise.reject(new Error('Error')) as never); + .mockRejectedValue(new Error('Error') as never); const callbacks = createCallbacks(); const result = await selectionByIndexesCommand.execute(instance, callbacks)({ @@ -601,7 +601,7 @@ describe('selectionByIndexesCommand', () => { Promise.resolve([{ id: 1, name: 'Alpha' }]) as never, ); jest.spyOn(instance, 'selectRows') - .mockReturnValue(Promise.reject(new Error('Error')) as never); + .mockRejectedValue(new Error('Error') as never); const callbacks = createCallbacks(); const result = await selectionByIndexesCommand.execute(instance, callbacks)({ @@ -617,7 +617,7 @@ describe('selectionByIndexesCommand', () => { Promise.resolve([{ id: 1, name: 'Alpha' }]) as never, ); jest.spyOn(instance, 'deselectRows') - .mockReturnValue(Promise.reject(new Error('Error')) as never); + .mockRejectedValue(new Error('Error') as never); const callbacks = createCallbacks(); const result = await selectionByIndexesCommand.execute(instance, callbacks)({ @@ -724,7 +724,7 @@ describe('selectionByIndexesCommand', () => { it('returns failure when selectRows rejects', async () => { const instance = await createGrid(); jest.spyOn(instance, 'selectRows') - .mockReturnValue(Promise.reject(new Error('Error')) as never); + .mockRejectedValue(new Error('Error') as never); const callbacks = createCallbacks(); const result = await executeWithTimers( 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 8c0942b4575e..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 @@ -181,10 +181,10 @@ const resolveKeysFromAllPages = ( 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"). ' - + 'Use this command for a SINGLE contiguous range per call. When the user asks for several non-contiguous ranges (e.g. "select rows 1 to 50 and 70 to 100"), invoke this command separately for EACH range — once with indexes [1, 2, ..., 50] and once with indexes [70, 71, ..., 100]. ' + '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.',