Skip to content

Commit f83ae87

Browse files
AI Assistant: Add possibility to select/deselect rows on any page depending on scope (#33865)
Co-authored-by: anna.shakhova <anna.shakhova@devexpress.com>
1 parent 069e8ea commit f83ae87

12 files changed

Lines changed: 789 additions & 157 deletions

File tree

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/selection.test.ts

Lines changed: 466 additions & 118 deletions
Large diffs are not rendered by default.

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/utils.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ import {
66
import { z } from 'zod';
77

88
import {
9+
isKeyShapeValid,
10+
normalizeKey,
911
// eslint-disable-next-line spellcheck/spell-checker
10-
isKeyShapeValid, normalizeKey, optionalNullish, resolveFilterValue,
12+
optionalNullish,
13+
resolveFilterValue,
14+
splitIntoLoadWindows,
1115
} from '../utils';
1216

1317
describe('normalizeKey', () => {
@@ -168,3 +172,33 @@ describe('resolveFilterValue', () => {
168172
expect(resolveFilterValue('date', true)).toBe(true);
169173
});
170174
});
175+
176+
describe('splitIntoLoadWindows', () => {
177+
it('returns an empty array for an empty input', () => {
178+
expect(splitIntoLoadWindows([], 10)).toEqual([]);
179+
});
180+
181+
it('wraps a single index into a single window', () => {
182+
expect(splitIntoLoadWindows([5], 10)).toEqual([[5]]);
183+
});
184+
185+
it('merges across gaps while the span stays within the window', () => {
186+
expect(splitIntoLoadWindows([1, 3, 5], 10)).toEqual([[1, 3, 5]]);
187+
});
188+
189+
it('starts a new window when the span would exceed the limit', () => {
190+
expect(splitIntoLoadWindows([1, 2, 4, 5, 6, 10], 3)).toEqual([
191+
[1, 2], [4, 5, 6], [10],
192+
]);
193+
});
194+
195+
it('sorts unsorted input before windowing', () => {
196+
expect(splitIntoLoadWindows([5, 1, 6, 2, 10, 7], 3)).toEqual([
197+
[1, 2], [5, 6, 7], [10],
198+
]);
199+
});
200+
201+
it('deduplicates repeated indexes', () => {
202+
expect(splitIntoLoadWindows([1, 1, 2, 2, 3], 10)).toEqual([[1, 2, 3]]);
203+
});
204+
});

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ import {
2525
clearSelectionCommand,
2626
deselectAllCommand,
2727
selectAllCommand,
28-
selectByIndexesCommand,
2928
selectByKeysCommand,
29+
selectionByIndexesCommand,
3030
} from './selection';
3131
import {
3232
clearSortingCommand,
@@ -49,7 +49,7 @@ export const coreCommands = [
4949
clearSelectionCommand,
5050
deselectAllCommand,
5151
selectAllCommand,
52-
selectByIndexesCommand,
52+
selectionByIndexesCommand,
5353
selectByKeysCommand,
5454
clearSortingCommand,
5555
sortingCommand,

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts

Lines changed: 154 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types';
2+
import type { InternalGrid, RowKey } from '@ts/grids/grid_core/m_types';
23
import { z } from 'zod';
34

45
import { defineGridCommand } from './defineGridCommand';
56
import {
6-
compositeKeyPairSchema, isKeyShapeValid, normalizeKey,
7+
compositeKeyPairSchema,
8+
isKeyShapeValid,
9+
normalizeKey,
10+
splitIntoLoadWindows,
711
} from './utils';
812

13+
const MAX_LOAD_WINDOW_PAGES = 5;
14+
915
const selectByKeysCommandSchema = z.object({
1016
keys: z.array(z.union([
1117
z.string(),
@@ -45,51 +51,167 @@ export const selectByKeysCommand = defineGridCommand({
4551
},
4652
});
4753

48-
const selectByIndexesCommandSchema = z.object({
54+
const selectionByIndexesCommandSchema = z.object({
4955
indexes: z.array(z.number().int().min(1)).min(1),
5056
mode: z.enum(['select', 'deselect']),
57+
scope: z.enum(['allPages', 'page']),
5158
}).strict();
5259

53-
export const selectByIndexesCommand = defineGridCommand({
54-
name: 'selectByIndexes',
55-
description: 'Select or deselect specific rows by their 1-based indexes within the current page. '
56-
+ 'Index 1 is the first row on the visible page; group/header rows are not addressable. '
57-
+ '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. '
58-
+ 'When mode is "select", the listed rows replace the current selection. '
59-
+ 'To target rows that are not on the current page, use selectByKeys, or call pageIndex first to switch the page. '
60-
+ 'To clear selection only within the current selectAll scope, use deselectAll; to clear selection across all pages regardless of selectAllMode, use clearSelection.',
61-
schema: selectByIndexesCommandSchema,
60+
// Maps 1-based indexes to the keys at those positions;
61+
// an index past the last entry rejects the whole set.
62+
const pickKeysByIndex = (
63+
keys: RowKey[],
64+
indexes: number[],
65+
): RowKey[] | null => {
66+
const normalizedRowIndexes = indexes.map((index) => index - 1);
67+
const allIndexesValid = normalizedRowIndexes.every(
68+
(index) => index < keys.length,
69+
);
70+
71+
if (!allIndexesValid) {
72+
return null;
73+
}
74+
75+
return normalizedRowIndexes.map((index) => keys[index]);
76+
};
77+
78+
const resolveKeysFromCurrentPage = (
79+
component: InternalGrid,
80+
indexes: number[],
81+
): RowKey[] | null => {
82+
// Group/footer rows are not counted, so indexes address the Nth data row.
83+
const items = component.getController('data').items();
84+
const dataItems = items.filter((item) => item.rowType === 'data');
85+
const dataKeys = dataItems.map((item) => item.key);
86+
87+
return pickKeysByIndex(dataKeys, indexes);
88+
};
89+
90+
const resolveKeysFromAllPagesRemote = async (
91+
component: InternalGrid,
92+
indexes: number[],
93+
): Promise<RowKey[] | null> => {
94+
const dataSource = component.getDataSource();
95+
const store = dataSource?.store();
96+
97+
if (!dataSource || !store) {
98+
return null;
99+
}
100+
101+
const keyExpr = component.option('keyExpr') ?? store.key();
102+
103+
if (!keyExpr) {
104+
return null;
105+
}
106+
107+
// Under grouping store.load returns group structures rather than flat rows,
108+
// so an index no longer maps to a single data row. Fail instead of resolving meaningless keys
109+
const grouping = dataSource.group();
110+
const isGrouped = Array.isArray(grouping) ? grouping.length > 0 : !!grouping;
111+
112+
if (isGrouped) {
113+
return null;
114+
}
115+
116+
const dataController = component.getController('data');
117+
const filter = dataController.getCombinedFilter(true);
118+
const baseLoadOptions = {
119+
...dataSource.loadOptions(),
120+
filter,
121+
};
122+
123+
const windows = splitIntoLoadWindows(indexes, dataSource.pageSize() * MAX_LOAD_WINDOW_PAGES);
124+
125+
const loadedWindows = await Promise.all(windows.map((window) => {
126+
const skip = window[0] - 1;
127+
const take = window[window.length - 1] - window[0] + 1;
128+
return store.load({ ...baseLoadOptions, skip, take })
129+
.then((result) => {
130+
const rows = Array.isArray(result) ? result : (result as { data: unknown[] }).data;
131+
return { window, rows };
132+
});
133+
}));
134+
135+
const keys: RowKey[] = [];
136+
137+
for (const { window, rows } of loadedWindows) {
138+
if (!Array.isArray(rows)) {
139+
return null;
140+
}
141+
142+
for (const index of window) {
143+
// The requested index maps to `window[0]` offset within the loaded rows
144+
const row = rows[index - window[0]];
145+
146+
if (row === undefined) {
147+
return null;
148+
}
149+
150+
keys.push(store.keyOf(row));
151+
}
152+
}
153+
154+
return keys;
155+
};
156+
157+
const resolveKeysFromAllPagesLocal = async (
158+
component: InternalGrid,
159+
indexes: number[],
160+
): Promise<RowKey[] | null> => {
161+
const keys = await component.getController('data').getAllDataRowKeys();
162+
163+
return pickKeysByIndex(keys, indexes);
164+
};
165+
166+
// Picks the "allPages" strategy by paging mode:
167+
// with local paging the full dataset is already on the client, so read the cache;
168+
// with remote paging rows are fetched by position.
169+
const resolveKeysFromAllPages = (
170+
component: InternalGrid,
171+
indexes: number[],
172+
): Promise<RowKey[] | null> => {
173+
const dataController = component.getController('data');
174+
const isRemotePaging = !!dataController.dataSource()?.remoteOperations()?.paging;
175+
176+
return isRemotePaging
177+
? resolveKeysFromAllPagesRemote(component, indexes)
178+
: resolveKeysFromAllPagesLocal(component, indexes);
179+
};
180+
181+
export const selectionByIndexesCommand = defineGridCommand({
182+
name: 'selectionByIndexes',
183+
description: 'Select or deselect rows by their 1-based indexes. '
184+
+ '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. '
185+
+ 'Always set scope to choose how indexes are interpreted: '
186+
+ '"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"). '
187+
+ '"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"). '
188+
+ '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"). '
189+
+ 'To clear selection only within the current selectAll scope, use deselectAll; to clear selection across all pages regardless of selectAllMode, use clearSelection. '
190+
+ 'To target rows by key value rather than by index, use selectByKeys.',
191+
schema: selectionByIndexesCommandSchema,
62192
execute: (component, { success, failure }) => async (args): Promise<CommandResult> => {
63193
const rowIndexes = args.indexes.join(', ');
64194
const action = args.mode === 'deselect' ? 'Deselect' : 'Select';
65-
const defaultMessage = `${action} row(s) number ${rowIndexes} on the current page.`;
195+
const scopeSuffix = args.scope === 'page' ? ' on the current page' : '';
196+
const defaultMessage = `${action} row(s) number ${rowIndexes}${scopeSuffix}.`;
66197

67198
if (component.option('selection.mode') === 'none') {
68199
return failure(defaultMessage);
69200
}
70201

71-
const items = component.getController('data').items();
72-
const normalizedRowIndexes = args.indexes.map((index) => index - 1);
73-
const allIndexesValid = normalizedRowIndexes.every(
74-
(index) => items[index]?.rowType === 'data',
75-
);
202+
try {
203+
const keys = args.scope === 'page'
204+
? resolveKeysFromCurrentPage(component, args.indexes)
205+
: await resolveKeysFromAllPages(component, args.indexes);
76206

77-
if (!allIndexesValid) {
78-
return failure(defaultMessage);
79-
}
207+
if (keys === null) {
208+
return failure(defaultMessage);
209+
}
80210

81-
try {
82-
switch (args.mode) {
83-
case 'deselect': {
84-
const itemKeys = normalizedRowIndexes.map((index) => items[index].key);
85-
await component.deselectRows(itemKeys);
86-
break;
87-
}
88-
case 'select':
89-
await component.selectRowsByIndexes(normalizedRowIndexes);
90-
break;
91-
default:
92-
return failure(defaultMessage);
211+
if (args.mode === 'deselect') {
212+
await component.deselectRows(keys);
213+
} else {
214+
await component.selectRows(keys, true);
93215
}
94216

95217
return success(defaultMessage);

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,27 @@ export const isKeyShapeValid = (
6767
return keyExpr.every((field) => field in key);
6868
};
6969

70+
export const splitIntoLoadWindows = (
71+
indexes: number[],
72+
maxWindowSize: number,
73+
): number[][] => {
74+
const sorted = [...new Set(indexes)].sort((a, b) => a - b);
75+
const windows: number[][] = [];
76+
77+
sorted.forEach((index) => {
78+
const current = windows.at(-1);
79+
80+
// Sorted indexes are merged into the same window within maxWindowSize
81+
if (current && index - current[0] + 1 <= maxWindowSize) {
82+
current.push(index);
83+
} else {
84+
windows.push([index]);
85+
}
86+
});
87+
88+
return windows;
89+
};
90+
7091
type FilterExprValue = BasicFilterExpr['value'];
7192

7293
export function resolveFilterValue(

packages/devextreme/js/__internal/grids/grid_core/data_controller/m_data_controller.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import type { ValidatingController } from '@ts/grids/grid_core/validating/m_vali
2323
import { AI_COLUMN_NAME } from '../ai_column/const';
2424
import modules from '../m_modules';
2525
import type {
26-
Controllers, Module,
26+
Controllers, Module, RowKey,
2727
} from '../m_types';
2828
import gridCoreUtils from '../m_utils';
2929
import type { VirtualScrollController } from '../virtual_scrolling/m_virtual_scrolling_core';
@@ -1542,6 +1542,13 @@ export class DataController extends DataHelperMixin(modules.Controller) {
15421542
return d;
15431543
}
15441544

1545+
public getAllDataRowKeys(): Promise<RowKey[]> {
1546+
return Promise.resolve(this.loadAll(undefined) as unknown as Promise<Item[]>)
1547+
.then((items) => items
1548+
.filter((item) => item.rowType === 'data')
1549+
.map((item) => item.key));
1550+
}
1551+
15451552
public getKeyByRowIndex(rowIndex, byLoaded?) {
15461553
const item = this.items(byLoaded)[rowIndex];
15471554
if (item) {

packages/devextreme/js/__internal/grids/tree_list/module_not_extended/ai_assistant.ts renamed to packages/devextreme/js/__internal/grids/tree_list/ai_assistant/ai_assistant.ts

File renamed without changes.

0 commit comments

Comments
 (0)