From 1b5ce9eed310860e4141feaa4f734d823a240d08 Mon Sep 17 00:00:00 2001
From: Alyar <>
Date: Fri, 28 Nov 2025 03:49:32 +0400
Subject: [PATCH 1/3] DataGrid - AI Column: Fix display of AI data during
virtual scrolling
---
.../pages/containerWithAIIntegration.html | 33 +++
.../aiColumn/virtualScrolling.functional.ts | 249 ++++++++++++++++++
.../tests/dataGrid/common/markup/markup.ts | 4 +-
.../controllers/m_ai_column_controller.ts | 46 +++-
.../data_controller/m_data_controller.ts | 11 +
.../virtual_scrolling/m_virtual_scrolling.ts | 12 +-
.../testing/helpers/gridBaseMocks.js | 4 +
packages/testcafe-models/dataGrid/index.ts | 16 +-
8 files changed, 360 insertions(+), 15 deletions(-)
create mode 100644 e2e/testcafe-devextreme/tests/dataGrid/common/aiColumn/pages/containerWithAIIntegration.html
create mode 100644 e2e/testcafe-devextreme/tests/dataGrid/common/aiColumn/virtualScrolling.functional.ts
diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/aiColumn/pages/containerWithAIIntegration.html b/e2e/testcafe-devextreme/tests/dataGrid/common/aiColumn/pages/containerWithAIIntegration.html
new file mode 100644
index 000000000000..a60d11d7b061
--- /dev/null
+++ b/e2e/testcafe-devextreme/tests/dataGrid/common/aiColumn/pages/containerWithAIIntegration.html
@@ -0,0 +1,33 @@
+
+
+
+ TestCafe Tests Container
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Test header
+
+
+
+
+
+
+
+
diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/aiColumn/virtualScrolling.functional.ts b/e2e/testcafe-devextreme/tests/dataGrid/common/aiColumn/virtualScrolling.functional.ts
new file mode 100644
index 000000000000..023f240a5fa6
--- /dev/null
+++ b/e2e/testcafe-devextreme/tests/dataGrid/common/aiColumn/virtualScrolling.functional.ts
@@ -0,0 +1,249 @@
+import DataGrid from 'devextreme-testcafe-models/dataGrid';
+import { ClientFunction } from 'testcafe';
+import url from '../../../../helpers/getPageUrl';
+import { createWidget } from '../../../../helpers/createWidget';
+
+fixture.disablePageReloads`Ai Column.Virtual Scrolling.Functional`
+ .page(url(__dirname, './pages/containerWithAIIntegration.html'));
+
+const DATA_GRID_SELECTOR = '#container';
+
+const checkAIColumnTexts = async (
+ t: TestController,
+ component: DataGrid,
+ expectedRowCount: number,
+): Promise => {
+ const visibleRows: Record[] = await component.apiGetVisibleRows();
+
+ await t.expect(visibleRows.length).eql(expectedRowCount);
+
+ // eslint-disable-next-line no-restricted-syntax
+ for (const row of visibleRows) {
+ await t
+ .expect(component.getDataCell(row.dataIndex, 3).element.textContent)
+ .eql(`Response ${row.data.name}`);
+ }
+};
+
+const resolveAIRequest = ClientFunction((): void => {
+ const { aiResponseData } = (window as any);
+ const { aiResolve } = (window as any);
+
+ if (aiResponseData && aiResolve) {
+ aiResolve(aiResponseData);
+
+ (window as any).aiResponseData = null;
+ (window as any).aiResolve = null;
+ }
+});
+
+const deleteGlobalVariables = ClientFunction((): void => {
+ delete (window as any).aiResponseData;
+ delete (window as any).aiResolve;
+});
+
+test('DataGrid should send an AI request for rendered rows after scrolling without changing the page index', async (t) => {
+ // arrange
+ const dataGrid = new DataGrid(DATA_GRID_SELECTOR);
+
+ // assert
+ await t
+ .expect(dataGrid.getLoadPanel().isVisible())
+ .ok();
+
+ // act
+ await resolveAIRequest();
+
+ // assert
+ await t
+ .expect(dataGrid.isReady())
+ .ok()
+ .expect(dataGrid.getLoadPanel().isVisible())
+ .notOk();
+ await checkAIColumnTexts(t, dataGrid, 11);
+
+ // act
+ await dataGrid.scrollTo(t, { y: 1000 });
+
+ // assert
+ await t
+ .expect(dataGrid.getScrollTop())
+ .eql(1000)
+ .expect(dataGrid.apiPageIndex())
+ .eql(0)
+ .expect(dataGrid.getDataCell(20, 0).element.textContent)
+ .eql('21')
+ .expect(dataGrid.getLoadPanel().isVisible())
+ .ok();
+
+ // act
+ await resolveAIRequest();
+
+ // assert
+ await t
+ .expect(dataGrid.isReady())
+ .ok()
+ .expect(dataGrid.getLoadPanel().isVisible())
+ .notOk();
+ await checkAIColumnTexts(t, dataGrid, 12);
+})
+ .before(async () => createWidget('dxDataGrid', () => {
+ const generateData = (rowCount: number): Record[] => {
+ const result: Record[] = [];
+
+ for (let i = 0; i < rowCount; i += 1) {
+ result.push({ id: i + 1, name: `Name ${i + 1}`, value: (i + 1) * 10 });
+ }
+
+ return result;
+ };
+
+ return {
+ dataSource: generateData(200),
+ height: 500,
+ keyExpr: 'id',
+ paging: {
+ pageSize: 50,
+ },
+ scrolling: {
+ mode: 'virtual',
+ },
+ columns: [
+ { dataField: 'id', caption: 'ID' },
+ { dataField: 'name', caption: 'Name' },
+ { dataField: 'value', caption: 'Value' },
+ {
+ type: 'ai',
+ caption: 'AI Column',
+ name: 'myColumn',
+ ai: {
+ prompt: 'Initial prompt',
+ // eslint-disable-next-line new-cap
+ aiIntegration: new (window as any).DevExpress.aiIntegration({
+ sendRequest(prompt) {
+ return {
+ promise: new Promise((resolve) => {
+ const result: Record = {};
+
+ Object.entries(prompt.data?.data).forEach(([key, value]) => {
+ result[key] = `Response ${(value as any).name}`;
+ });
+
+ (window as any).aiResponseData = JSON.stringify(result);
+ (window as any).aiResolve = resolve;
+ }),
+ abort: (): void => {},
+ };
+ },
+ }),
+ },
+ },
+ ],
+ };
+ }))
+ .after(async () => {
+ await deleteGlobalVariables();
+ });
+
+test('DataGrid should send an AI request for rendered rows after scrolling with changing the page index', async (t) => {
+ // arrange
+ const dataGrid = new DataGrid(DATA_GRID_SELECTOR);
+
+ // assert
+ await t
+ .expect(dataGrid.getLoadPanel().isVisible())
+ .ok();
+
+ // act
+ await resolveAIRequest();
+
+ // assert
+ await t
+ .expect(dataGrid.isReady())
+ .ok()
+ .expect(dataGrid.getLoadPanel().isVisible())
+ .notOk();
+ await checkAIColumnTexts(t, dataGrid, 11);
+
+ // act
+ await dataGrid.scrollTo(t, { y: 1000 });
+
+ // assert
+ await t
+ .expect(dataGrid.getScrollTop())
+ .eql(1000)
+ .expect(dataGrid.apiPageIndex())
+ .eql(1)
+ .expect(dataGrid.getDataCell(20, 0).element.textContent)
+ .eql('21')
+ .expect(dataGrid.getLoadPanel().isVisible())
+ .ok();
+
+ // act
+ await resolveAIRequest();
+
+ // assert
+ await t
+ .expect(dataGrid.isReady())
+ .ok()
+ .expect(dataGrid.getLoadPanel().isVisible())
+ .notOk();
+ await checkAIColumnTexts(t, dataGrid, 12);
+})
+ .before(async () => createWidget('dxDataGrid', () => {
+ const generateData = (rowCount: number): Record[] => {
+ const result: Record[] = [];
+
+ for (let i = 0; i < rowCount; i += 1) {
+ result.push({ id: i + 1, name: `Name ${i + 1}`, value: (i + 1) * 10 });
+ }
+
+ return result;
+ };
+
+ return {
+ dataSource: generateData(200),
+ height: 500,
+ keyExpr: 'id',
+ paging: {
+ pageSize: 20,
+ },
+ scrolling: {
+ mode: 'virtual',
+ },
+ columns: [
+ { dataField: 'id', caption: 'ID' },
+ { dataField: 'name', caption: 'Name' },
+ { dataField: 'value', caption: 'Value' },
+ {
+ type: 'ai',
+ caption: 'AI Column',
+ name: 'myColumn',
+ ai: {
+ prompt: 'Initial prompt',
+ // eslint-disable-next-line new-cap
+ aiIntegration: new (window as any).DevExpress.aiIntegration({
+ sendRequest(prompt) {
+ return {
+ promise: new Promise((resolve) => {
+ const result: Record = {};
+
+ Object.entries(prompt.data?.data).forEach(([key, value]) => {
+ result[key] = `Response ${(value as any).name}`;
+ });
+
+ (window as any).aiResponseData = JSON.stringify(result);
+ (window as any).aiResolve = resolve;
+ }),
+ abort: (): void => {},
+ };
+ },
+ }),
+ },
+ },
+ ],
+ };
+ }))
+ .after(async () => {
+ await deleteGlobalVariables();
+ });
diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/markup/markup.ts b/e2e/testcafe-devextreme/tests/dataGrid/common/markup/markup.ts
index 9fdce76cef5f..0d0255054250 100644
--- a/e2e/testcafe-devextreme/tests/dataGrid/common/markup/markup.ts
+++ b/e2e/testcafe-devextreme/tests/dataGrid/common/markup/markup.ts
@@ -10,9 +10,9 @@ test('Load panel should support string height and width', async (t) => {
await dataGrid.apiBeginCustomLoading('test');
await t
- .expect(dataGrid.getLoadPanel().content.getStyleProperty('height'))
+ .expect(dataGrid.getLoadPanel().getContent().getStyleProperty('height'))
.eql('400px')
- .expect(dataGrid.getLoadPanel().content.getStyleProperty('width'))
+ .expect(dataGrid.getLoadPanel().getContent().getStyleProperty('width'))
.eql('330px');
}).before(async () => createWidget('dxDataGrid', {
dataSource: [],
diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_controller.ts
index 6b4b80a3d7f3..8bc715a371ad 100644
--- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_controller.ts
+++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_controller.ts
@@ -5,6 +5,7 @@ import { isDefined } from '@ts/core/utils/m_type';
import type { Column, ColumnsController } from '../../columns_controller/m_columns_controller';
import type { DataController, HandleDataChangedArguments, UserData } from '../../data_controller/m_data_controller';
import { Controller } from '../../m_modules';
+import gridCoreUtils from '../../m_utils';
import type { InternalRequestCallbacks } from '../types';
import { getAICommandColumnDefaultOptions, isAIColumnAutoMode, isPromptOption } from '../utils';
import { AIColumnIntegrationController } from './m_ai_column_integration_controller';
@@ -24,6 +25,8 @@ export class AIColumnController extends Controller {
private storeBeforePushHandler!: ({ changes }: { changes: DataChange[] }) => void;
+ private dataControllerChangedHandler!: () => void;
+
private aiColumnOptionChangedHandler!: (
column: Column,
optionName: string,
@@ -64,6 +67,31 @@ export class AIColumnController extends Controller {
this.dataController.dataSource()?.changed.add(this.dataSourceChangedHandler);
}
+ private unsubscribeFromDataControllerChanged(): void {
+ if (!this.dataControllerChangedHandler) {
+ return;
+ }
+
+ this.dataController.changed.remove(this.dataControllerChangedHandler);
+ }
+
+ private subscribeToDataControllerChanged(): void {
+ if (!this.getAIColumns().length || !gridCoreUtils.isVirtualRowRendering(this)) {
+ return;
+ }
+
+ this.dataControllerChangedHandler = this.dataControllerChangedHandler
+ ?? this.handleDataControllerChanged.bind(this);
+
+ this.dataController.changed.add(this.dataControllerChangedHandler.bind(this));
+ }
+
+ private handleDataControllerChanged(): void {
+ if (this.dataController.isViewportChanging()) {
+ this.sendRequests();
+ }
+ }
+
private unsubscribeFromStoreEvents(): void {
const store = this.dataController.store();
@@ -137,12 +165,10 @@ export class AIColumnController extends Controller {
});
}
- private handleDataSourceChanged(args?: HandleDataChangedArguments): void {
+ private sendRequests(): void {
const aiColumns = this.getAIColumns();
- if (args?.changeType === 'loadError'
- || aiColumns.length === 0
- || !this.checkStoreKey()) {
+ if (!aiColumns.length || !this.checkStoreKey()) {
return;
}
@@ -153,6 +179,14 @@ export class AIColumnController extends Controller {
}
}
+ private handleDataSourceChanged(args?: HandleDataChangedArguments): void {
+ if (args?.changeType === 'loadError') {
+ return;
+ }
+
+ this.sendRequests();
+ }
+
protected callbackNames(): string[] {
return ['aiRequestCompleted', 'aiRequestRejected'];
}
@@ -172,6 +206,9 @@ export class AIColumnController extends Controller {
this.unsubscribeFromStoreEvents();
this.subscribeToStoreEvents();
+ this.unsubscribeFromDataControllerChanged();
+ this.subscribeToDataControllerChanged();
+
this.addAICommandColumn();
}
@@ -280,5 +317,6 @@ export class AIColumnController extends Controller {
super.dispose();
this.dataController.dataSource()?.changed.remove(this.dataSourceChangedHandler);
this.unsubscribeFromStoreEvents();
+ this.unsubscribeFromDataControllerChanged();
}
}
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 d493ab1b4a4f..315d6d20b1d9 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
@@ -1632,6 +1632,10 @@ export class DataController extends DataHelperMixin(modules.Controller) {
return changePaging(this, 'pageSize', value);
}
+ public isCustomLoading() {
+ return this._isCustomLoading;
+ }
+
public beginCustomLoading(messageText?: string) {
this._isCustomLoading = true;
this._loadingText = messageText ?? '';
@@ -1780,6 +1784,13 @@ export class DataController extends DataHelperMixin(modules.Controller) {
return Object.keys(operationTypes).some((type) => operationTypes[type]);
}
+
+ /**
+ * @extended: virtual_scrolling
+ */
+ public isViewportChanging(): boolean {
+ return false;
+ }
}
export const dataControllerModule: Module = {
defaultOptions() {
diff --git a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling.ts b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling.ts
index bd1eeb5979d7..e5d2cfbeca87 100644
--- a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling.ts
+++ b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling.ts
@@ -564,10 +564,6 @@ export const data = (Base: ModuleType) => class VirtualScrolling
}
}
- private isViewportChanging() {
- return this._viewportChanging;
- }
-
private _getRowsScrollDataOptions() {
const that = this;
const isItemCountable = function (item) {
@@ -1289,6 +1285,10 @@ export const data = (Base: ModuleType) => class VirtualScrolling
private setViewportItemIndex() {
return this._dataSource?.setViewportItemIndex.apply(this._dataSource, arguments as any);
}
+
+ public isViewportChanging(): boolean {
+ return this._viewportChanging || super.isViewportChanging();
+ }
};
export const resizing = (Base: ModuleType) => class VirtualScrollingResizingControllerExtender extends Base {
@@ -1780,9 +1780,9 @@ export const rowsView = (Base: ModuleType) => class VirtualScrollingRo
public setLoading(isLoading, messageText) {
const dataController = this._dataController;
const hasBottomLoadPanel = dataController.pageIndex() > 0 && dataController.isLoaded() && !!this._findBottomLoadPanel();
+ const isDefaultLoading = isLoading && !this._dataController.isCustomLoading();
- // @ts-expect-error
- if (this.option(LEGACY_SCROLLING_MODE) === false && isLoading && dataController.isViewportChanging()) {
+ if (this.option(LEGACY_SCROLLING_MODE) === false && isDefaultLoading && dataController.isViewportChanging()) {
return;
}
diff --git a/packages/devextreme/testing/helpers/gridBaseMocks.js b/packages/devextreme/testing/helpers/gridBaseMocks.js
index f2aef4cd8ad4..1cd526f839b2 100644
--- a/packages/devextreme/testing/helpers/gridBaseMocks.js
+++ b/packages/devextreme/testing/helpers/gridBaseMocks.js
@@ -180,6 +180,10 @@ module.exports = function($, gridCore, columnResizingReordering, domUtils, commo
return false;
},
+ isCustomLoading: function() {
+ return false;
+ },
+
isStateLoading: function() {
return false;
},
diff --git a/packages/testcafe-models/dataGrid/index.ts b/packages/testcafe-models/dataGrid/index.ts
index f3cbed98ebd0..8adce5990c5d 100644
--- a/packages/testcafe-models/dataGrid/index.ts
+++ b/packages/testcafe-models/dataGrid/index.ts
@@ -55,7 +55,7 @@ export const CLASS = {
overlayContent: 'dx-overlay-content',
overlayWrapper: 'dx-overlay-wrapper',
- loadPanelWrapper: 'dx-loadpanel-wrapper',
+ loadPanel: 'dx-loadpanel',
revertTooltip: 'revert-tooltip',
invalidMessage: 'invalid-message',
@@ -264,7 +264,7 @@ export default class DataGrid extends GridCore {
}
getLoadPanel(): LoadPanel {
- return new LoadPanel(this.element.find(`.${CLASS.loadPanelWrapper}`));
+ return new LoadPanel(this.element.find(`.${CLASS.loadPanel}`));
}
getConfirmDeletionButton(): Selector {
@@ -562,6 +562,8 @@ export default class DataGrid extends GridCore {
const dataGrid = getInstance() as any;
return dataGrid.getVisibleRows().map((r) => ({
key: r.key,
+ data: r.data,
+ dataIndex: r.dataIndex,
rowType: r.rowType,
}));
}, { dependencies: { getInstance } })();
@@ -709,7 +711,6 @@ export default class DataGrid extends GridCore {
)();
}
-
apiFocus(): Promise {
const { getInstance } = this;
@@ -723,6 +724,15 @@ export default class DataGrid extends GridCore {
)();
}
+ apiPageIndex(): Promise {
+ const { getInstance } = this;
+
+ return ClientFunction(
+ () => (getInstance() as any).pageIndex(),
+ { dependencies: { getInstance } },
+ )();
+ }
+
moveRow(rowIndex: number, x: number, y: number, isStart = false): Promise {
const { getInstance } = this;
From ad7a218762f394de19615c4b5144e30fe2c6a295 Mon Sep 17 00:00:00 2001
From: Alyar <>
Date: Fri, 28 Nov 2025 04:19:59 +0400
Subject: [PATCH 2/3] Fix test and copilot comment
---
.../dataGrid/common/aiColumn/virtualScrolling.functional.ts | 2 +-
.../grid_core/ai_column/controllers/m_ai_column_controller.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/aiColumn/virtualScrolling.functional.ts b/e2e/testcafe-devextreme/tests/dataGrid/common/aiColumn/virtualScrolling.functional.ts
index 023f240a5fa6..05792231c154 100644
--- a/e2e/testcafe-devextreme/tests/dataGrid/common/aiColumn/virtualScrolling.functional.ts
+++ b/e2e/testcafe-devextreme/tests/dataGrid/common/aiColumn/virtualScrolling.functional.ts
@@ -3,7 +3,7 @@ import { ClientFunction } from 'testcafe';
import url from '../../../../helpers/getPageUrl';
import { createWidget } from '../../../../helpers/createWidget';
-fixture.disablePageReloads`Ai Column.Virtual Scrolling.Functional`
+fixture`Ai Column.Virtual Scrolling.Functional`
.page(url(__dirname, './pages/containerWithAIIntegration.html'));
const DATA_GRID_SELECTOR = '#container';
diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_controller.ts
index 8bc715a371ad..2d5c38834e52 100644
--- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_controller.ts
+++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_controller.ts
@@ -83,7 +83,7 @@ export class AIColumnController extends Controller {
this.dataControllerChangedHandler = this.dataControllerChangedHandler
?? this.handleDataControllerChanged.bind(this);
- this.dataController.changed.add(this.dataControllerChangedHandler.bind(this));
+ this.dataController.changed.add(this.dataControllerChangedHandler);
}
private handleDataControllerChanged(): void {
From 07b884b41d0a125f3486908bb636ed1a7526e416 Mon Sep 17 00:00:00 2001
From: Alyar <>
Date: Fri, 28 Nov 2025 13:52:41 +0400
Subject: [PATCH 3/3] small code refactoring
---
.../grids/grid_core/virtual_scrolling/m_virtual_scrolling.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling.ts b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling.ts
index e5d2cfbeca87..d41c314c4986 100644
--- a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling.ts
+++ b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling.ts
@@ -1780,7 +1780,7 @@ export const rowsView = (Base: ModuleType) => class VirtualScrollingRo
public setLoading(isLoading, messageText) {
const dataController = this._dataController;
const hasBottomLoadPanel = dataController.pageIndex() > 0 && dataController.isLoaded() && !!this._findBottomLoadPanel();
- const isDefaultLoading = isLoading && !this._dataController.isCustomLoading();
+ const isDefaultLoading = isLoading && !dataController.isCustomLoading();
if (this.option(LEGACY_SCROLLING_MODE) === false && isDefaultLoading && dataController.isViewportChanging()) {
return;