From c3ce9442597e33ade3131019d8e89e1986f719ae Mon Sep 17 00:00:00 2001 From: "anna.shakhova" <68295572+anna-shakhova@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:11:36 +0200 Subject: [PATCH 1/8] Grids: extract AI data tests into a separate file --- .../__tests__/ai_column.ai_data.test.ts | 352 ++++++++++++++++++ .../__tests__/ai_column.integration.test.ts | 322 ---------------- 2 files changed, 352 insertions(+), 322 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.ai_data.test.ts diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.ai_data.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.ai_data.test.ts new file mode 100644 index 000000000000..0385f28b8973 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.ai_data.test.ts @@ -0,0 +1,352 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import type { GenerateGridColumnCommandResponse } from '@js/common/ai-integration'; +import type { LoadOptions } from '@js/data'; +import DataSource from '@js/data/data_source'; +import errors from '@js/ui/widget/ui.errors'; +import { AIIntegration } from '@ts/core/ai_integration/core/ai_integration'; +import ArrayStore from '@ts/data/m_array_store'; + +import { + afterTest, + beforeTest as baseBeforeTest, + createDataGrid, +} from '../../__tests__/__mock__/helpers/utils'; +import { CLASSES } from '../const'; + +const EMPTY_CELL_TEXT = '\u00A0'; + +const items = [ + { id: 1, name: 'Name 1', value: 10 }, + { id: 2, name: 'Name 2', value: 20 }, +]; + +interface RequestResult { + promise: Promise; + abort: () => void; +} + +const beforeTest = (): void => { + baseBeforeTest(); + jest.spyOn(errors, 'log').mockImplementation(jest.fn()); + jest.spyOn(errors, 'Error').mockImplementation(() => ({})); +}; +describe('AI data', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + const store = new ArrayStore(items); + const loadMock = jest.fn(( + loadOptions: LoadOptions, + ): Promise => new Promise((resolve, reject) => { + setTimeout(() => { + store.load(loadOptions).done(resolve).fail(reject); + }, 300); + })); + const totalCountMock = jest.fn((): Promise => new Promise((resolve, reject) => { + store.totalCount().done(resolve).fail(reject); + })); + + const remoteDataSource = new DataSource({ + key: 'id', + load: loadMock, + totalCount: totalCountMock, + }); + const compareCellNodes = ( + prevCells: (HTMLElement | null)[], + currentCells: (HTMLElement | null)[], + ): void => { + prevCells.forEach((cell: HTMLElement | null, index: number) => { + const currentCell = currentCells[index]; + + if (cell === null || currentCell === null) { + throw new Error('Cell is null'); + } + + if (cell.classList.contains(CLASSES.aiColumn)) { + expect(cell).not.toBe(currentCell); + } else { + expect(cell).toBe(currentCell); + } + }); + }; + + describe('when prompt is set', () => { + it('should be rendered', async () => { + const { component } = await createDataGrid({ + dataSource: items, + keyExpr: 'id', + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myColumn', + ai: { + prompt: 'Initial prompt', + aiIntegration: new AIIntegration({ + sendRequest(): RequestResult { + return { + promise: new Promise((resolve) => { + resolve('{"1":"AI Response 1","2":"AI Response 2"}'); + }), + abort: (): void => {}, + }; + }, + }), + }, + }, + ], + }); + + expect(component.getDataCell(0, 3).getText()).toBe('AI Response 1'); + expect(component.getDataCell(1, 3).getText()).toBe('AI Response 2'); + }); + }); + + describe('when prompt is set via column option', () => { + it('should be rendered', async () => { + const { component } = await createDataGrid({ + dataSource: items, + keyExpr: 'id', + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myColumn', + ai: { + aiIntegration: new AIIntegration({ + sendRequest(): RequestResult { + return { + promise: new Promise((resolve) => { + resolve('{"1":"AI Response 1","2":"AI Response 2"}'); + }), + abort: (): void => {}, + }; + }, + }), + }, + }, + ], + }); + + expect(component.getDataCell(0, 3).getText()).toBe(EMPTY_CELL_TEXT); + expect(component.getDataCell(1, 3).getText()).toBe(EMPTY_CELL_TEXT); + + component.apiColumnOption('myColumn', 'ai.prompt', 'Initial prompt'); + await Promise.resolve(); + + expect(component.getDataCell(0, 3).getText()).toBe('AI Response 1'); + expect(component.getDataCell(1, 3).getText()).toBe('AI Response 2'); + }); + }); + + describe('when prompt is set via AI prompt editor', () => { + it('should be rendered', async () => { + const { component } = await createDataGrid({ + dataSource: items, + keyExpr: 'id', + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myColumn', + ai: { + popup: { + visible: true, + }, + aiIntegration: new AIIntegration({ + sendRequest(): RequestResult { + return { + promise: new Promise((resolve) => { + resolve('{"1":"AI Response 1","2":"AI Response 2"}'); + }), + abort: (): void => {}, + }; + }, + }), + }, + }, + ], + }); + + expect(component.getDataCell(0, 3).getText()).toBe(EMPTY_CELL_TEXT); + expect(component.getDataCell(1, 3).getText()).toBe(EMPTY_CELL_TEXT); + + component.getAIPromptEditor().getTextArea().setValue('Initial prompt'); + component.getAIPromptEditor().getApplyButton().getElement().click(); + + await Promise.resolve(); + + expect(component.getDataCell(0, 3).getText()).toBe('AI Response 1'); + expect(component.getDataCell(1, 3).getText()).toBe('AI Response 2'); + }); + }); + + describe('when prompt is set when there are multiple AI columns', () => { + it('should be rendered in the correct column', async () => { + const { component } = await createDataGrid({ + dataSource: items, + keyExpr: 'id', + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column 1', + name: 'myColumn1', + ai: { + aiIntegration: new AIIntegration({ + sendRequest(): RequestResult { + return { + promise: new Promise((resolve) => { + resolve('{"1":"AI Column 1 - AI Response 1","2":"AI Column 1 - AI Response 2"}'); + }), + abort: (): void => {}, + }; + }, + }), + }, + }, + { + type: 'ai', + caption: 'AI Column 2', + name: 'myColumn2', + ai: { + prompt: 'Initial prompt', + aiIntegration: new AIIntegration({ + sendRequest(): RequestResult { + return { + promise: new Promise((resolve) => { + resolve('{"1":"AI Column 2 - AI Response 1","2":"AI Column 2 - AI Response 2"}'); + }), + abort: (): void => {}, + }; + }, + }), + }, + }, + ], + }); + + // check data cells of the first AI column + expect(component.getDataCell(0, 3).getText()).toBe(EMPTY_CELL_TEXT); + expect(component.getDataCell(1, 3).getText()).toBe(EMPTY_CELL_TEXT); + + // check data cells of the second AI column + expect(component.getDataCell(0, 4).getText()).toBe('AI Column 2 - AI Response 1'); + expect(component.getDataCell(1, 4).getText()).toBe('AI Column 2 - AI Response 2'); + }); + }); + + describe('when refresh is called', () => { + it('should be re-rendered', async () => { + const { component } = await createDataGrid({ + dataSource: items, + keyExpr: 'id', + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myColumn', + ai: { + prompt: 'Initial prompt', + aiIntegration: new AIIntegration({ + sendRequest(): RequestResult { + return { + promise: new Promise((resolve) => { + resolve('{"1":"AI Response 1","2":"AI Response 2"}'); + }), + abort: (): void => {}, + }; + }, + }), + }, + }, + ], + }); + + jest.useRealTimers(); + + const aiCells = [ + component.getDataCell(0, 3).getElement(), + component.getDataCell(1, 3).getElement(), + ]; + + await component.apiRefresh(); + + compareCellNodes( + aiCells, + [ + component.getDataCell(0, 3).getElement(), + component.getDataCell(1, 3).getElement(), + ], + ); + }); + }); + + describe('when remoteOperations is enabled and refresh is called', () => { + it('should be re-rendered', async () => { + const { component } = await createDataGrid({ + dataSource: remoteDataSource, + remoteOperations: true, + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myColumn', + ai: { + prompt: 'Initial prompt', + aiIntegration: new AIIntegration({ + sendRequest(): RequestResult { + return { + promise: new Promise((resolve) => { + resolve('{"1":"AI Response 1","2":"AI Response 2"}'); + }), + abort: (): void => {}, + }; + }, + }), + }, + }, + ], + }); + + jest.useRealTimers(); + + const aiCells = [ + component.getDataCell(0, 3).getElement(), + component.getDataCell(1, 3).getElement(), + ]; + + expect(loadMock).toHaveBeenCalledTimes(1); + + await component.apiRefresh(); + + expect(loadMock).toHaveBeenCalledTimes(2); + compareCellNodes( + aiCells, + [ + component.getDataCell(0, 3).getElement(), + component.getDataCell(1, 3).getElement(), + ], + ); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.integration.test.ts index ce845362f310..84f5af3c2068 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.integration.test.ts @@ -3,11 +3,8 @@ import { } from '@jest/globals'; import type { GenerateGridColumnCommandResponse, RequestParams } from '@js/common/ai-integration'; import $ from '@js/core/renderer'; -import type { LoadOptions } from '@js/data'; -import DataSource from '@js/data/data_source'; import errors from '@js/ui/widget/ui.errors'; import { AIIntegration } from '@ts/core/ai_integration/core/ai_integration'; -import ArrayStore from '@ts/data/m_array_store'; import { afterTest, @@ -4021,325 +4018,6 @@ describe('Cache', () => { }); }); -describe('AI data', () => { - beforeEach(beforeTest); - afterEach(afterTest); - - const store = new ArrayStore(items); - const loadMock = jest.fn(( - loadOptions: LoadOptions, - ): Promise => new Promise((resolve, reject) => { - setTimeout(() => { - store.load(loadOptions).done(resolve).fail(reject); - }, 300); - })); - const totalCountMock = jest.fn((): Promise => new Promise((resolve, reject) => { - store.totalCount().done(resolve).fail(reject); - })); - - const remoteDataSource = new DataSource({ - key: 'id', - load: loadMock, - totalCount: totalCountMock, - }); - const compareCellNodes = ( - prevCells: (HTMLElement | null)[], - currentCells: (HTMLElement | null)[], - ): void => { - prevCells.forEach((cell: HTMLElement | null, index: number) => { - const currentCell = currentCells[index]; - - if (cell === null || currentCell === null) { - throw new Error('Cell is null'); - } - - if (cell.classList.contains(CLASSES.aiColumn)) { - expect(cell).not.toBe(currentCell); - } else { - expect(cell).toBe(currentCell); - } - }); - }; - - describe('when prompt is set', () => { - it('should be rendered', async () => { - const { component } = await createDataGrid({ - dataSource: items, - keyExpr: 'id', - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myColumn', - ai: { - prompt: 'Initial prompt', - aiIntegration: new AIIntegration({ - sendRequest(): RequestResult { - return { - promise: new Promise((resolve) => { - resolve('{"1":"AI Response 1","2":"AI Response 2"}'); - }), - abort: (): void => {}, - }; - }, - }), - }, - }, - ], - }); - - expect(component.getDataCell(0, 3).getText()).toBe('AI Response 1'); - expect(component.getDataCell(1, 3).getText()).toBe('AI Response 2'); - }); - }); - - describe('when prompt is set via column option', () => { - it('should be rendered', async () => { - const { component } = await createDataGrid({ - dataSource: items, - keyExpr: 'id', - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myColumn', - ai: { - aiIntegration: new AIIntegration({ - sendRequest(): RequestResult { - return { - promise: new Promise((resolve) => { - resolve('{"1":"AI Response 1","2":"AI Response 2"}'); - }), - abort: (): void => {}, - }; - }, - }), - }, - }, - ], - }); - - expect(component.getDataCell(0, 3).getText()).toBe(EMPTY_CELL_TEXT); - expect(component.getDataCell(1, 3).getText()).toBe(EMPTY_CELL_TEXT); - - component.apiColumnOption('myColumn', 'ai.prompt', 'Initial prompt'); - await Promise.resolve(); - - expect(component.getDataCell(0, 3).getText()).toBe('AI Response 1'); - expect(component.getDataCell(1, 3).getText()).toBe('AI Response 2'); - }); - }); - - describe('when prompt is set via AI prompt editor', () => { - it('should be rendered', async () => { - const { component } = await createDataGrid({ - dataSource: items, - keyExpr: 'id', - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myColumn', - ai: { - popup: { - visible: true, - }, - aiIntegration: new AIIntegration({ - sendRequest(): RequestResult { - return { - promise: new Promise((resolve) => { - resolve('{"1":"AI Response 1","2":"AI Response 2"}'); - }), - abort: (): void => {}, - }; - }, - }), - }, - }, - ], - }); - - expect(component.getDataCell(0, 3).getText()).toBe(EMPTY_CELL_TEXT); - expect(component.getDataCell(1, 3).getText()).toBe(EMPTY_CELL_TEXT); - - component.getAIPromptEditor().getTextArea().setValue('Initial prompt'); - component.getAIPromptEditor().getApplyButton().getElement().click(); - - await Promise.resolve(); - - expect(component.getDataCell(0, 3).getText()).toBe('AI Response 1'); - expect(component.getDataCell(1, 3).getText()).toBe('AI Response 2'); - }); - }); - - describe('when prompt is set when there are multiple AI columns', () => { - it('should be rendered in the correct column', async () => { - const { component } = await createDataGrid({ - dataSource: items, - keyExpr: 'id', - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column 1', - name: 'myColumn1', - ai: { - aiIntegration: new AIIntegration({ - sendRequest(): RequestResult { - return { - promise: new Promise((resolve) => { - resolve('{"1":"AI Column 1 - AI Response 1","2":"AI Column 1 - AI Response 2"}'); - }), - abort: (): void => {}, - }; - }, - }), - }, - }, - { - type: 'ai', - caption: 'AI Column 2', - name: 'myColumn2', - ai: { - prompt: 'Initial prompt', - aiIntegration: new AIIntegration({ - sendRequest(): RequestResult { - return { - promise: new Promise((resolve) => { - resolve('{"1":"AI Column 2 - AI Response 1","2":"AI Column 2 - AI Response 2"}'); - }), - abort: (): void => {}, - }; - }, - }), - }, - }, - ], - }); - - // check data cells of the first AI column - expect(component.getDataCell(0, 3).getText()).toBe(EMPTY_CELL_TEXT); - expect(component.getDataCell(1, 3).getText()).toBe(EMPTY_CELL_TEXT); - - // check data cells of the second AI column - expect(component.getDataCell(0, 4).getText()).toBe('AI Column 2 - AI Response 1'); - expect(component.getDataCell(1, 4).getText()).toBe('AI Column 2 - AI Response 2'); - }); - }); - - describe('when refresh is called', () => { - it('should be re-rendered', async () => { - const { component } = await createDataGrid({ - dataSource: items, - keyExpr: 'id', - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myColumn', - ai: { - prompt: 'Initial prompt', - aiIntegration: new AIIntegration({ - sendRequest(): RequestResult { - return { - promise: new Promise((resolve) => { - resolve('{"1":"AI Response 1","2":"AI Response 2"}'); - }), - abort: (): void => {}, - }; - }, - }), - }, - }, - ], - }); - - jest.useRealTimers(); - - const aiCells = [ - component.getDataCell(0, 3).getElement(), - component.getDataCell(1, 3).getElement(), - ]; - - await component.apiRefresh(); - - compareCellNodes( - aiCells, - [ - component.getDataCell(0, 3).getElement(), - component.getDataCell(1, 3).getElement(), - ], - ); - }); - }); - - describe('when remoteOperations is enabled and refresh is called', () => { - it('should be re-rendered', async () => { - const { component } = await createDataGrid({ - dataSource: remoteDataSource, - remoteOperations: true, - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myColumn', - ai: { - prompt: 'Initial prompt', - aiIntegration: new AIIntegration({ - sendRequest(): RequestResult { - return { - promise: new Promise((resolve) => { - resolve('{"1":"AI Response 1","2":"AI Response 2"}'); - }), - abort: (): void => {}, - }; - }, - }), - }, - }, - ], - }); - - jest.useRealTimers(); - - const aiCells = [ - component.getDataCell(0, 3).getElement(), - component.getDataCell(1, 3).getElement(), - ]; - - expect(loadMock).toHaveBeenCalledTimes(1); - - await component.apiRefresh(); - - expect(loadMock).toHaveBeenCalledTimes(2); - compareCellNodes( - aiCells, - [ - component.getDataCell(0, 3).getElement(), - component.getDataCell(1, 3).getElement(), - ], - ); - }); - }); -}); - describe('Load panel', () => { beforeEach(beforeTest); afterEach(afterTest); From 109d337074261e92149ead5f51df74df63abe3f8 Mon Sep 17 00:00:00 2001 From: "anna.shakhova" <68295572+anna-shakhova@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:16:29 +0200 Subject: [PATCH 2/8] Grids: support compound keys in AI Column --- .../__tests__/ai_column.ai_data.test.ts | 48 ++++++++++++++++++- .../controllers/m_ai_column_controller.ts | 2 +- .../m_ai_column_integration_controller.ts | 23 ++++++--- .../grids/grid_core/ai_column/utils.test.ts | 8 ++-- .../grids/grid_core/ai_column/utils.ts | 4 +- 5 files changed, 70 insertions(+), 15 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.ai_data.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.ai_data.test.ts index 0385f28b8973..e36a5e1fb173 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.ai_data.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.ai_data.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, jest, } from '@jest/globals'; -import type { GenerateGridColumnCommandResponse } from '@js/common/ai-integration'; +import type { GenerateGridColumnCommandResponse, RequestParams } from '@js/common/ai-integration'; import type { LoadOptions } from '@js/data'; import DataSource from '@js/data/data_source'; import errors from '@js/ui/widget/ui.errors'; @@ -12,6 +12,7 @@ import { afterTest, beforeTest as baseBeforeTest, createDataGrid, + flushAsync, } from '../../__tests__/__mock__/helpers/utils'; import { CLASSES } from '../const'; @@ -349,4 +350,49 @@ describe('AI data', () => { ); }); }); + + describe('when the key is compound', () => { + it('should render each row\'s own AI value', async () => { + let sentKeys: string[] = []; + const aiIntegration = new AIIntegration({ + sendRequest(params: RequestParams): RequestResult { + const dataset = params.data?.data as Record; + sentKeys = Object.keys(dataset); + const result: Record = {}; + Object.entries(dataset).forEach(([key, value]) => { + result[key] = `AI ${value.name}`; + }); + return { + promise: Promise.resolve(JSON.stringify(result)), + abort: (): void => {}, + }; + }, + }); + + const { component } = await createDataGrid({ + keyExpr: ['id1', 'id2'], + dataSource: [ + { id1: 1, id2: 'a', name: 'Name 1' }, + { id1: 1, id2: 'b', name: 'Name 2' }, + ], + columns: [ + { dataField: 'id1' }, + { dataField: 'id2' }, + { dataField: 'name' }, + { + type: 'ai', + name: 'aiColumn', + caption: 'AI Column', + ai: { aiIntegration, prompt: 'Test prompt' }, + }, + ], + }); + + await flushAsync(); + + expect(sentKeys).toHaveLength(2); + expect(component.getDataCell(0, 3).getText()).toBe('AI Name 1'); + expect(component.getDataCell(1, 3).getText()).toBe('AI Name 2'); + }); + }); }); 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 0157a4227a33..5f9ec1938617 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 @@ -302,7 +302,7 @@ export class AIColumnController extends Controller { } public getAIColumnText(columnName: string, key: unknown): string | undefined { - return this.aiColumnIntegrationController.getAIColumnText(columnName, key as PropertyKey); + return this.aiColumnIntegrationController.getAIColumnText(columnName, key); } public aiColumnOptionChanged( diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_integration_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_integration_controller.ts index fffa1a230ebb..048a3d86159b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_integration_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_integration_controller.ts @@ -3,10 +3,11 @@ import type { GenerateGridColumnCommandResult, RequestCallbacks, } from '@js/common/ai-integration'; +import { getKeyHash } from '@js/core/utils/common'; import errors from '@js/ui/widget/ui.errors'; import type { ColumnsController } from '../../columns_controller/m_columns_controller'; -import type { DataController } from '../../data_controller/m_data_controller'; +import type { DataController, UserData } from '../../data_controller/m_data_controller'; import type { ErrorHandlingController } from '../../error_handling/m_error_handling'; import { Controller } from '../../m_modules'; import type { InternalRequestCallbacks } from '../types'; @@ -74,6 +75,10 @@ export class AIColumnIntegrationController extends Controller { return callbacks; } + private getRowKeyHash(item: UserData): PropertyKey { + return getKeyHash(this.dataController.keyOf(item)) as PropertyKey; + } + public init(): void { this.columnsController = this.getController('columns'); this.dataController = this.getController('data'); @@ -137,11 +142,15 @@ export class AIColumnIntegrationController extends Controller { let cachedResponse: Record = {}; if (args.useCache) { - const keys = data.map((item) => item[keyField] as PropertyKey); + const keys = data.map((item) => this.getRowKeyHash(item)); cachedResponse = this.aiColumnCacheController.getCachedResponse(columnName, keys); } - const reducedData = reduceDataCachedKeys(args.data, cachedResponse, keyField); + const reducedData = reduceDataCachedKeys( + args.data, + cachedResponse, + (item) => this.getRowKeyHash(item), + ); const areAllDataCached = Object.keys(reducedData).length === 0; if (areAllDataCached) { @@ -178,16 +187,16 @@ export class AIColumnIntegrationController extends Controller { this.errorHandlingController?.showToastError(message); } - public getAIColumnText(columnName: string, key: PropertyKey): string | undefined { - return this.aiColumnCacheController.getCachedString(columnName, key); + public getAIColumnText(columnName: string, key: unknown): string | undefined { + return this.aiColumnCacheController.getCachedString(columnName, getKeyHash(key) as PropertyKey); } public clearAIColumn(columnName: string): void { this.aiColumnCacheController.clearCache(columnName); } - public clearAIColumnByKey(columnName: string, key: PropertyKey): void { - this.aiColumnCacheController.clearCacheByKey(columnName, key); + public clearAIColumnByKey(columnName: string, key: unknown): void { + this.aiColumnCacheController.clearCacheByKey(columnName, getKeyHash(key) as PropertyKey); } public dispose(): void { diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.test.ts index d50f38d9a60e..a541d45002da 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.test.ts @@ -25,7 +25,7 @@ describe('reduceDataCachedKeys', () => { b: 'test b', c: 'test c', }; - const result = reduceDataCachedKeys(data, cachedData, 'key'); + const result = reduceDataCachedKeys(data, cachedData, (item) => item.key as PropertyKey); expect(result).toEqual( { a: { key: 'a', value: '1' } }, ); @@ -38,7 +38,7 @@ describe('reduceDataCachedKeys', () => { { key: 'c', value: '3' }, ]; const cachedKeys = {}; - const result = reduceDataCachedKeys(data, cachedKeys, 'key'); + const result = reduceDataCachedKeys(data, cachedKeys, (item) => item.key as PropertyKey); expect(result).toEqual({ a: { key: 'a', value: '1' }, b: { key: 'b', value: '2' }, @@ -57,7 +57,7 @@ describe('reduceDataCachedKeys', () => { b: 'test b', c: 'test c', }; - const result = reduceDataCachedKeys(data, cachedData, 'key'); + const result = reduceDataCachedKeys(data, cachedData, (item) => item.key as PropertyKey); expect(result).toEqual({ }); }); @@ -71,7 +71,7 @@ describe('reduceDataCachedKeys', () => { 2: 'two', 3: 'three', }; - const result = reduceDataCachedKeys(data, cachedKeys, 'key'); + const result = reduceDataCachedKeys(data, cachedKeys, (item) => item.key as PropertyKey); expect(result).toEqual({ 1: { key: 1, value: '1' }, }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.ts index 849c0d9a21de..f0a1a0fbad00 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.ts @@ -20,11 +20,11 @@ export const getDataFromRowItems = (items: Item[]): UserData[] => items export const reduceDataCachedKeys = ( data: UserData[], cachedData: Record, - keyField: string, + getKey: (item: UserData) => PropertyKey, ): Record => { const newData: Record = { }; for (const item of data) { - const key = item[keyField] as PropertyKey; + const key = getKey(item); if (!(key in cachedData)) { newData[key] = item; } From 336f91db9293144b1fe3c68b4dddc694da8ee927 Mon Sep 17 00:00:00 2001 From: "anna.shakhova" <68295572+anna-shakhova@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:27:35 +0200 Subject: [PATCH 3/8] Grids: extract Cache tests into a separate file --- .../__tests__/ai_column.cache.test.ts | 597 ++++++++++++++++++ .../__tests__/ai_column.integration.test.ts | 575 ----------------- 2 files changed, 597 insertions(+), 575 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts new file mode 100644 index 000000000000..34c5fd654050 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts @@ -0,0 +1,597 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import type { GenerateGridColumnCommandResponse, RequestParams } from '@js/common/ai-integration'; +import errors from '@js/ui/widget/ui.errors'; +import { AIIntegration } from '@ts/core/ai_integration/core/ai_integration'; + +import { + afterTest, + beforeTest as baseBeforeTest, + createDataGrid, +} from '../../__tests__/__mock__/helpers/utils'; + +interface RequestResult { + promise: Promise; + abort: () => void; +} + +const beforeTest = (): void => { + baseBeforeTest(); + jest.spyOn(errors, 'log').mockImplementation(jest.fn()); + jest.spyOn(errors, 'Error').mockImplementation(() => ({})); +}; +describe('Cache', () => { + const sendRequestSpy = jest.fn(); + + beforeEach(() => { + beforeTest(); + sendRequestSpy.mockClear(); + }); + + afterEach(afterTest); + + describe('when use public methods', () => { + it('should not use cached data with sendAIColumnRequest', async () => { + const aiIntegration = new AIIntegration({ + sendRequest(prompt): RequestResult { + sendRequestSpy(); + + return { + promise: new Promise((resolve) => { + const result = {}; + Object.entries(prompt.data?.data).forEach(([key, value]) => { + result[key] = `Response ${(value as any).name}`; + }); + + resolve(JSON.stringify(result)); + }), + abort: (): void => {}, + }; + }, + }); + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Name 1', value: 10 }, + { id: 2, name: 'Name 2', value: 20 }, + ], + keyExpr: 'id', + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myColumn', + ai: { + aiIntegration, + mode: 'manual', + prompt: 'Test prompt', + }, + }, + ], + }); + + instance.sendAIColumnRequest('myColumn'); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + + instance.sendAIColumnRequest('myColumn'); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + + instance.sendAIColumnRequest('myColumn'); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(3); + }); + + it('should not use cached data with refreshAIColumn', async () => { + const aiIntegration = new AIIntegration({ + sendRequest(prompt): RequestResult { + sendRequestSpy(); + + return { + promise: new Promise((resolve) => { + const result = {}; + Object.entries(prompt.data?.data).forEach(([key, value]) => { + result[key] = `Response ${(value as any).name}`; + }); + + resolve(JSON.stringify(result)); + }), + abort: (): void => {}, + }; + }, + }); + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Name 1', value: 10 }, + { id: 2, name: 'Name 2', value: 20 }, + ], + keyExpr: 'id', + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myColumn', + ai: { + aiIntegration, + mode: 'manual', + prompt: 'Test prompt', + }, + }, + ], + }); + + instance.refreshAIColumn('myColumn'); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + + instance.refreshAIColumn('myColumn'); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + + instance.refreshAIColumn('myColumn'); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(3); + }); + }); + + describe('when update column options', () => { + it('should clear cached data on ai.prompt change', async () => { + const aiIntegration = new AIIntegration({ + sendRequest(prompt): RequestResult { + sendRequestSpy(); + + return { + promise: new Promise((resolve) => { + const result = {}; + Object.entries(prompt.data?.data).forEach(([key, value]) => { + result[key] = `Response ${(value as any).name}`; + }); + + resolve(JSON.stringify(result)); + }), + abort: (): void => {}, + }; + }, + }); + const { component, instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Name 1', value: 10 }, + { id: 2, name: 'Name 2', value: 20 }, + ], + keyExpr: 'id', + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myColumn', + ai: { + aiIntegration, + mode: 'manual', + prompt: 'Test prompt', + }, + }, + ], + }); + + instance.sendAIColumnRequest('myColumn'); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(instance.getAIColumnText('myColumn', 1)).toEqual('Response Name 1'); + expect(instance.getAIColumnText('myColumn', 2)).toEqual('Response Name 2'); + + component.apiColumnOption('myColumn', 'ai.prompt', 'Updated prompt'); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(instance.getAIColumnText('myColumn', 1)).toBeUndefined(); + expect(instance.getAIColumnText('myColumn', 2)).toBeUndefined(); + + instance.sendAIColumnRequest('myColumn'); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + expect(instance.getAIColumnText('myColumn', 1)).toEqual('Response Name 1'); + expect(instance.getAIColumnText('myColumn', 2)).toEqual('Response Name 2'); + }); + + it('should use cache with pagination in auto mode', async () => { + const aiIntegration = new AIIntegration({ + sendRequest(prompt): RequestResult { + sendRequestSpy(prompt.data?.data); + + return { + promise: new Promise((resolve) => { + const result = {}; + Object.entries(prompt.data?.data).forEach(([key, value]) => { + result[key] = `Response ${(value as any).name}`; + }); + resolve(JSON.stringify(result)); + }), + abort: (): void => {}, + }; + }, + }); + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Name 1', value: 10 }, + { id: 2, name: 'Name 2', value: 20 }, + ], + keyExpr: 'id', + paging: { + pageSize: 1, + }, + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myColumn', + ai: { + aiIntegration, + prompt: 'Test prompt', + }, + }, + ], + }); + + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(sendRequestSpy).toHaveBeenCalledWith({ 1: { id: 1, name: 'Name 1', value: 10 } }); + + instance.option('paging.pageIndex', 1); + jest.runAllTimers(); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + expect(sendRequestSpy).toHaveBeenCalledWith({ 2: { id: 2, name: 'Name 2', value: 20 } }); + + instance.option('paging.pageIndex', 0); + jest.runAllTimers(); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + }); + + it('should use cache with filtering in auto mode', async () => { + const aiIntegration = new AIIntegration({ + sendRequest(prompt): RequestResult { + sendRequestSpy(prompt.data?.data); + return { + promise: new Promise((resolve) => { + const result = {}; + Object.entries(prompt.data?.data).forEach(([key, value]) => { + result[key] = `Response ${(value as any).name}`; + }); + resolve(JSON.stringify(result)); + }), + abort: (): void => {}, + }; + }, + }); + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Name 1', value: 10 }, + { id: 2, name: 'Name 2', value: 20 }, + ], + keyExpr: 'id', + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myColumn', + ai: { + aiIntegration, + prompt: 'Test prompt', + }, + }, + ], + filterValue: ['id', '=', 1], + }); + + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(sendRequestSpy).toHaveBeenCalledWith({ 1: { id: 1, name: 'Name 1', value: 10 } }); + + instance.option('filterValue', undefined); + jest.runAllTimers(); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + expect(sendRequestSpy).toHaveBeenCalledWith({ 2: { id: 2, name: 'Name 2', value: 20 } }); + }); + }); + + describe('common behavior', () => { + it('should not cache empty responses', async () => { + const aiIntegrationResult = (prompt): RequestResult => ({ + promise: new Promise((resolve) => { + const result = {}; + Object.entries(prompt.data?.data).forEach(([key]) => { + result[key] = ''; + }); + + resolve(JSON.stringify(result)); + }), + abort: (): void => {}, + }); + const columnAIIntegration = new AIIntegration({ + sendRequest(prompt): RequestResult { + sendRequestSpy(); + return aiIntegrationResult(prompt); + }, + }); + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Name 1', value: 10 }, + ], + keyExpr: 'id', + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myColumn', + ai: { + aiIntegration: columnAIIntegration, + mode: 'manual', + prompt: 'Test prompt', + }, + }, + ], + }); + + expect(instance.getAIColumnText('myColumn', 1)).toBeUndefined(); + instance.sendAIColumnRequest('myColumn'); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(instance.getAIColumnText('myColumn', 1)).toBe(''); + + instance.sendAIColumnRequest('myColumn'); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + expect(instance.getAIColumnText('myColumn', 1)).toBe(''); + }); + }); + + describe('when data is updated', () => { + it('should clear cached data and send a prompt request', async () => { + const aiIntegration = new AIIntegration({ + sendRequest(prompt: RequestParams): RequestResult { + sendRequestSpy(prompt.data?.data); + + return { + promise: new Promise((resolve) => { + resolve(`{"1":"Response with value=${prompt.data?.data[1].value}"}`); + }), + abort: (): void => {}, + }; + }, + }); + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Name 1', value: 10 }, + ], + editing: { + mode: 'batch', + allowUpdating: true, + }, + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myAIColumn', + ai: { + aiIntegration, + prompt: 'Initial prompt', + }, + }, + ], + }); + + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=10'); + + instance.cellValue(0, 'value', 20); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + instance.saveEditData(); // This method returns a non-native Promise + jest.runAllTimers(); + await Promise.resolve(); + + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + expect(sendRequestSpy).toHaveBeenLastCalledWith({ 1: { id: 1, name: 'Name 1', value: 20 } }); + expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=20'); + }); + }); + + describe('when data is removed', () => { + it('should clear cached data without sending a new prompt request', async () => { + const aiIntegration = new AIIntegration({ + sendRequest(prompt: RequestParams): RequestResult { + sendRequestSpy(prompt.data?.data); + + return { + promise: new Promise((resolve) => { + resolve(`{"1":"Response with value=${prompt.data?.data[1].value}"}`); + }), + abort: (): void => {}, + }; + }, + }); + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Name 1', value: 10 }, + ], + editing: { + mode: 'batch', + allowUpdating: true, + }, + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myAIColumn', + ai: { + aiIntegration, + prompt: 'Initial prompt', + }, + }, + ], + }); + + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=10'); + + instance.deleteRow(0); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + instance.saveEditData(); // This method returns a non-native Promise + jest.runAllTimers(); + await Promise.resolve(); + + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(instance.getAIColumnText('myAIColumn', 1)).toEqual(undefined); + }); + }); + + describe('when data is added', () => { + it('should send a prompt request', async () => { + const aiIntegration = new AIIntegration({ + sendRequest(prompt: RequestParams): RequestResult { + sendRequestSpy(prompt.data?.data); + + return { + promise: new Promise((resolve) => { + const result = {}; + + Object.entries(prompt.data?.data).forEach(([key, value]) => { + result[key] = `Response with value=${(value as any).value}`; + }); + + resolve(JSON.stringify(result)); + }), + abort: (): void => {}, + }; + }, + }); + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Name 1', value: 10 }, + ], + editing: { + mode: 'batch', + allowUpdating: true, + }, + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myAIColumn', + ai: { + aiIntegration, + prompt: 'Initial prompt', + }, + }, + ], + }); + + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=10'); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + instance.addRow(); // This method returns a non-native Promise + jest.runAllTimers(); + await Promise.resolve(); + + instance.cellValue(0, 'value', 20); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + instance.saveEditData(); // This method returns a non-native Promise + jest.runAllTimers(); + await Promise.resolve(); + + const visibleRows = instance.getVisibleRows(); + expect(visibleRows[0].key).toEqual(1); // existing row + expect(visibleRows[1].key).toBeDefined(); // new row + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + expect(sendRequestSpy).toHaveBeenLastCalledWith({ + [visibleRows[1].key]: { id: visibleRows[1].key, value: 20 }, + }); + expect(instance.getAIColumnText('myAIColumn', visibleRows[1].key)).toEqual('Response with value=20'); + }); + }); + + describe('when data is updated via Push API', () => { + it('should clear cached data and send a prompt request', async () => { + const aiIntegration = new AIIntegration({ + sendRequest(prompt: RequestParams): RequestResult { + sendRequestSpy(prompt.data?.data); + + return { + promise: new Promise((resolve) => { + resolve(`{"1":"Response with value=${prompt.data?.data[1].value}"}`); + }), + abort: (): void => {}, + }; + }, + }); + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Name 1', value: 10 }, + ], + editing: { + mode: 'batch', + allowUpdating: true, + }, + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myAIColumn', + ai: { + aiIntegration, + prompt: 'Initial prompt', + }, + }, + ], + }); + + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=10'); + + instance.getDataSource().store().push([{ + type: 'update', + key: 1, + data: { value: 20 }, + }]); + jest.runAllTimers(); + await Promise.resolve(); + + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + expect(sendRequestSpy).toHaveBeenLastCalledWith({ 1: { id: 1, name: 'Name 1', value: 20 } }); + expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=20'); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.integration.test.ts index 84f5af3c2068..74b5b20b3089 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.integration.test.ts @@ -3443,581 +3443,6 @@ describe('DropDownButton', () => { }); }); -describe('Cache', () => { - const sendRequestSpy = jest.fn(); - - beforeEach(() => { - beforeTest(); - sendRequestSpy.mockClear(); - }); - - afterEach(afterTest); - - describe('when use public methods', () => { - it('should not use cached data with sendAIColumnRequest', async () => { - const aiIntegration = new AIIntegration({ - sendRequest(prompt): RequestResult { - sendRequestSpy(); - - return { - promise: new Promise((resolve) => { - const result = {}; - Object.entries(prompt.data?.data).forEach(([key, value]) => { - result[key] = `Response ${(value as any).name}`; - }); - - resolve(JSON.stringify(result)); - }), - abort: (): void => {}, - }; - }, - }); - const { instance } = await createDataGrid({ - dataSource: [ - { id: 1, name: 'Name 1', value: 10 }, - { id: 2, name: 'Name 2', value: 20 }, - ], - keyExpr: 'id', - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myColumn', - ai: { - aiIntegration, - mode: 'manual', - prompt: 'Test prompt', - }, - }, - ], - }); - - instance.sendAIColumnRequest('myColumn'); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(1); - - instance.sendAIColumnRequest('myColumn'); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(2); - - instance.sendAIColumnRequest('myColumn'); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(3); - }); - - it('should not use cached data with refreshAIColumn', async () => { - const aiIntegration = new AIIntegration({ - sendRequest(prompt): RequestResult { - sendRequestSpy(); - - return { - promise: new Promise((resolve) => { - const result = {}; - Object.entries(prompt.data?.data).forEach(([key, value]) => { - result[key] = `Response ${(value as any).name}`; - }); - - resolve(JSON.stringify(result)); - }), - abort: (): void => {}, - }; - }, - }); - const { instance } = await createDataGrid({ - dataSource: [ - { id: 1, name: 'Name 1', value: 10 }, - { id: 2, name: 'Name 2', value: 20 }, - ], - keyExpr: 'id', - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myColumn', - ai: { - aiIntegration, - mode: 'manual', - prompt: 'Test prompt', - }, - }, - ], - }); - - instance.refreshAIColumn('myColumn'); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(1); - - instance.refreshAIColumn('myColumn'); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(2); - - instance.refreshAIColumn('myColumn'); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(3); - }); - }); - - describe('when update column options', () => { - it('should clear cached data on ai.prompt change', async () => { - const aiIntegration = new AIIntegration({ - sendRequest(prompt): RequestResult { - sendRequestSpy(); - - return { - promise: new Promise((resolve) => { - const result = {}; - Object.entries(prompt.data?.data).forEach(([key, value]) => { - result[key] = `Response ${(value as any).name}`; - }); - - resolve(JSON.stringify(result)); - }), - abort: (): void => {}, - }; - }, - }); - const { component, instance } = await createDataGrid({ - dataSource: [ - { id: 1, name: 'Name 1', value: 10 }, - { id: 2, name: 'Name 2', value: 20 }, - ], - keyExpr: 'id', - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myColumn', - ai: { - aiIntegration, - mode: 'manual', - prompt: 'Test prompt', - }, - }, - ], - }); - - instance.sendAIColumnRequest('myColumn'); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(1); - expect(instance.getAIColumnText('myColumn', 1)).toEqual('Response Name 1'); - expect(instance.getAIColumnText('myColumn', 2)).toEqual('Response Name 2'); - - component.apiColumnOption('myColumn', 'ai.prompt', 'Updated prompt'); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(1); - expect(instance.getAIColumnText('myColumn', 1)).toBeUndefined(); - expect(instance.getAIColumnText('myColumn', 2)).toBeUndefined(); - - instance.sendAIColumnRequest('myColumn'); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(2); - expect(instance.getAIColumnText('myColumn', 1)).toEqual('Response Name 1'); - expect(instance.getAIColumnText('myColumn', 2)).toEqual('Response Name 2'); - }); - - it('should use cache with pagination in auto mode', async () => { - const aiIntegration = new AIIntegration({ - sendRequest(prompt): RequestResult { - sendRequestSpy(prompt.data?.data); - - return { - promise: new Promise((resolve) => { - const result = {}; - Object.entries(prompt.data?.data).forEach(([key, value]) => { - result[key] = `Response ${(value as any).name}`; - }); - resolve(JSON.stringify(result)); - }), - abort: (): void => {}, - }; - }, - }); - const { instance } = await createDataGrid({ - dataSource: [ - { id: 1, name: 'Name 1', value: 10 }, - { id: 2, name: 'Name 2', value: 20 }, - ], - keyExpr: 'id', - paging: { - pageSize: 1, - }, - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myColumn', - ai: { - aiIntegration, - prompt: 'Test prompt', - }, - }, - ], - }); - - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(1); - expect(sendRequestSpy).toHaveBeenCalledWith({ 1: { id: 1, name: 'Name 1', value: 10 } }); - - instance.option('paging.pageIndex', 1); - jest.runAllTimers(); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(2); - expect(sendRequestSpy).toHaveBeenCalledWith({ 2: { id: 2, name: 'Name 2', value: 20 } }); - - instance.option('paging.pageIndex', 0); - jest.runAllTimers(); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(2); - }); - - it('should use cache with filtering in auto mode', async () => { - const aiIntegration = new AIIntegration({ - sendRequest(prompt): RequestResult { - sendRequestSpy(prompt.data?.data); - return { - promise: new Promise((resolve) => { - const result = {}; - Object.entries(prompt.data?.data).forEach(([key, value]) => { - result[key] = `Response ${(value as any).name}`; - }); - resolve(JSON.stringify(result)); - }), - abort: (): void => {}, - }; - }, - }); - const { instance } = await createDataGrid({ - dataSource: [ - { id: 1, name: 'Name 1', value: 10 }, - { id: 2, name: 'Name 2', value: 20 }, - ], - keyExpr: 'id', - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myColumn', - ai: { - aiIntegration, - prompt: 'Test prompt', - }, - }, - ], - filterValue: ['id', '=', 1], - }); - - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(1); - expect(sendRequestSpy).toHaveBeenCalledWith({ 1: { id: 1, name: 'Name 1', value: 10 } }); - - instance.option('filterValue', undefined); - jest.runAllTimers(); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(2); - expect(sendRequestSpy).toHaveBeenCalledWith({ 2: { id: 2, name: 'Name 2', value: 20 } }); - }); - }); - - describe('common behavior', () => { - it('should not cache empty responses', async () => { - const aiIntegrationResult = (prompt): RequestResult => ({ - promise: new Promise((resolve) => { - const result = {}; - Object.entries(prompt.data?.data).forEach(([key]) => { - result[key] = ''; - }); - - resolve(JSON.stringify(result)); - }), - abort: (): void => {}, - }); - const columnAIIntegration = new AIIntegration({ - sendRequest(prompt): RequestResult { - sendRequestSpy(); - return aiIntegrationResult(prompt); - }, - }); - const { instance } = await createDataGrid({ - dataSource: [ - { id: 1, name: 'Name 1', value: 10 }, - ], - keyExpr: 'id', - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myColumn', - ai: { - aiIntegration: columnAIIntegration, - mode: 'manual', - prompt: 'Test prompt', - }, - }, - ], - }); - - expect(instance.getAIColumnText('myColumn', 1)).toBeUndefined(); - instance.sendAIColumnRequest('myColumn'); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(1); - expect(instance.getAIColumnText('myColumn', 1)).toBe(''); - - instance.sendAIColumnRequest('myColumn'); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(2); - expect(instance.getAIColumnText('myColumn', 1)).toBe(''); - }); - }); - - describe('when data is updated', () => { - it('should clear cached data and send a prompt request', async () => { - const aiIntegration = new AIIntegration({ - sendRequest(prompt: RequestParams): RequestResult { - sendRequestSpy(prompt.data?.data); - - return { - promise: new Promise((resolve) => { - resolve(`{"1":"Response with value=${prompt.data?.data[1].value}"}`); - }), - abort: (): void => {}, - }; - }, - }); - const { instance } = await createDataGrid({ - dataSource: [ - { id: 1, name: 'Name 1', value: 10 }, - ], - editing: { - mode: 'batch', - allowUpdating: true, - }, - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myAIColumn', - ai: { - aiIntegration, - prompt: 'Initial prompt', - }, - }, - ], - }); - - expect(sendRequestSpy).toHaveBeenCalledTimes(1); - expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=10'); - - instance.cellValue(0, 'value', 20); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - instance.saveEditData(); // This method returns a non-native Promise - jest.runAllTimers(); - await Promise.resolve(); - - expect(sendRequestSpy).toHaveBeenCalledTimes(2); - expect(sendRequestSpy).toHaveBeenLastCalledWith({ 1: { id: 1, name: 'Name 1', value: 20 } }); - expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=20'); - }); - }); - - describe('when data is removed', () => { - it('should clear cached data without sending a new prompt request', async () => { - const aiIntegration = new AIIntegration({ - sendRequest(prompt: RequestParams): RequestResult { - sendRequestSpy(prompt.data?.data); - - return { - promise: new Promise((resolve) => { - resolve(`{"1":"Response with value=${prompt.data?.data[1].value}"}`); - }), - abort: (): void => {}, - }; - }, - }); - const { instance } = await createDataGrid({ - dataSource: [ - { id: 1, name: 'Name 1', value: 10 }, - ], - editing: { - mode: 'batch', - allowUpdating: true, - }, - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myAIColumn', - ai: { - aiIntegration, - prompt: 'Initial prompt', - }, - }, - ], - }); - - expect(sendRequestSpy).toHaveBeenCalledTimes(1); - expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=10'); - - instance.deleteRow(0); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - instance.saveEditData(); // This method returns a non-native Promise - jest.runAllTimers(); - await Promise.resolve(); - - expect(sendRequestSpy).toHaveBeenCalledTimes(1); - expect(instance.getAIColumnText('myAIColumn', 1)).toEqual(undefined); - }); - }); - - describe('when data is added', () => { - it('should send a prompt request', async () => { - const aiIntegration = new AIIntegration({ - sendRequest(prompt: RequestParams): RequestResult { - sendRequestSpy(prompt.data?.data); - - return { - promise: new Promise((resolve) => { - const result = {}; - - Object.entries(prompt.data?.data).forEach(([key, value]) => { - result[key] = `Response with value=${(value as any).value}`; - }); - - resolve(JSON.stringify(result)); - }), - abort: (): void => {}, - }; - }, - }); - const { instance } = await createDataGrid({ - dataSource: [ - { id: 1, name: 'Name 1', value: 10 }, - ], - editing: { - mode: 'batch', - allowUpdating: true, - }, - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myAIColumn', - ai: { - aiIntegration, - prompt: 'Initial prompt', - }, - }, - ], - }); - - expect(sendRequestSpy).toHaveBeenCalledTimes(1); - expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=10'); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - instance.addRow(); // This method returns a non-native Promise - jest.runAllTimers(); - await Promise.resolve(); - - instance.cellValue(0, 'value', 20); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - instance.saveEditData(); // This method returns a non-native Promise - jest.runAllTimers(); - await Promise.resolve(); - - const visibleRows = instance.getVisibleRows(); - expect(visibleRows[0].key).toEqual(1); // existing row - expect(visibleRows[1].key).toBeDefined(); // new row - expect(sendRequestSpy).toHaveBeenCalledTimes(2); - expect(sendRequestSpy).toHaveBeenLastCalledWith({ - [visibleRows[1].key]: { id: visibleRows[1].key, value: 20 }, - }); - expect(instance.getAIColumnText('myAIColumn', visibleRows[1].key)).toEqual('Response with value=20'); - }); - }); - - describe('when data is updated via Push API', () => { - it('should clear cached data and send a prompt request', async () => { - const aiIntegration = new AIIntegration({ - sendRequest(prompt: RequestParams): RequestResult { - sendRequestSpy(prompt.data?.data); - - return { - promise: new Promise((resolve) => { - resolve(`{"1":"Response with value=${prompt.data?.data[1].value}"}`); - }), - abort: (): void => {}, - }; - }, - }); - const { instance } = await createDataGrid({ - dataSource: [ - { id: 1, name: 'Name 1', value: 10 }, - ], - editing: { - mode: 'batch', - allowUpdating: true, - }, - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myAIColumn', - ai: { - aiIntegration, - prompt: 'Initial prompt', - }, - }, - ], - }); - - expect(sendRequestSpy).toHaveBeenCalledTimes(1); - expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=10'); - - instance.getDataSource().store().push([{ - type: 'update', - key: 1, - data: { value: 20 }, - }]); - jest.runAllTimers(); - await Promise.resolve(); - - expect(sendRequestSpy).toHaveBeenCalledTimes(2); - expect(sendRequestSpy).toHaveBeenLastCalledWith({ 1: { id: 1, name: 'Name 1', value: 20 } }); - expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=20'); - }); - }); -}); - describe('Load panel', () => { beforeEach(beforeTest); afterEach(afterTest); From 6a5ec0dc01a0e8662369506ec5e86a4c10e06b50 Mon Sep 17 00:00:00 2001 From: "anna.shakhova" <68295572+anna-shakhova@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:42:21 +0200 Subject: [PATCH 4/8] Grids: add test for AI Column cache reuse for compound keys --- .../__tests__/ai_column.cache.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts index 34c5fd654050..131578429d73 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts @@ -260,6 +260,63 @@ describe('Cache', () => { expect(sendRequestSpy).toHaveBeenCalledTimes(2); }); + it('should use cache with pagination in auto mode (compound key)', async () => { + const aiIntegration = new AIIntegration({ + sendRequest(prompt): RequestResult { + sendRequestSpy(prompt.data?.data); + return { + promise: new Promise((resolve) => { + const result = {}; + Object.entries(prompt.data?.data).forEach(([key, value]) => { + result[key] = `Response ${(value as any).name}`; + }); + resolve(JSON.stringify(result)); + }), + abort: (): void => {}, + }; + }, + }); + const { instance } = await createDataGrid({ + dataSource: [ + { id1: 1, id2: 'a', name: 'Name 1' }, + { id1: 2, id2: 'b', name: 'Name 2' }, + ], + keyExpr: ['id1', 'id2'], + paging: { + pageSize: 1, + }, + columns: [ + { dataField: 'id1', caption: 'ID1' }, + { dataField: 'id2', caption: 'ID2' }, + { dataField: 'name', caption: 'Name' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myColumn', + ai: { + aiIntegration, + prompt: 'Test prompt', + }, + }, + ], + }); + + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(sendRequestSpy).toHaveBeenCalledWith({ '{"id1":1,"id2":"a"}': { id1: 1, id2: 'a', name: 'Name 1' } }); + + instance.option('paging.pageIndex', 1); + jest.runAllTimers(); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + expect(sendRequestSpy).toHaveBeenCalledWith({ '{"id1":2,"id2":"b"}': { id1: 2, id2: 'b', name: 'Name 2' } }); + + instance.option('paging.pageIndex', 0); + jest.runAllTimers(); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + }); + it('should use cache with filtering in auto mode', async () => { const aiIntegration = new AIIntegration({ sendRequest(prompt): RequestResult { From 3e7e5c887ef14fe35936b066c894467ac4c15e6d Mon Sep 17 00:00:00 2001 From: "anna.shakhova" <68295572+anna-shakhova@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:55:45 +0200 Subject: [PATCH 5/8] Grids: handle compound keys in AI Column cache invalidation --- .../__tests__/ai_column.cache.test.ts | 62 +++++++++++++++++++ .../controllers/m_ai_column_controller.ts | 13 ++-- .../m_ai_column_integration_controller.ts | 5 +- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts index 131578429d73..61584c0d55d4 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts @@ -651,4 +651,66 @@ describe('Cache', () => { expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=20'); }); }); + + describe('when a compound-key row is updated via Push API', () => { + it('should clear cached data for the correct row and send a prompt request', async () => { + const aiIntegration = new AIIntegration({ + sendRequest(prompt: RequestParams): RequestResult { + sendRequestSpy(prompt.data?.data); + + return { + promise: new Promise((resolve) => { + const result = {}; + Object.entries(prompt.data?.data).forEach(([key, value]) => { + result[key] = `Response with value=${(value as any).value}`; + }); + resolve(JSON.stringify(result)); + }), + abort: (): void => {}, + }; + }, + }); + const { instance } = await createDataGrid({ + dataSource: [ + { id1: 1, id2: 'a', value: 10 }, + { id1: 2, id2: 'b', value: 20 }, + ], + keyExpr: ['id1', 'id2'], + columns: [ + { dataField: 'id1' }, + { dataField: 'id2' }, + { dataField: 'value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myAIColumn', + ai: { + aiIntegration, + prompt: 'Initial prompt', + }, + }, + ], + }); + + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(instance.getAIColumnText('myAIColumn', { id1: 1, id2: 'a' })).toEqual('Response with value=10'); + expect(instance.getAIColumnText('myAIColumn', { id1: 2, id2: 'b' })).toEqual('Response with value=20'); + + instance.getDataSource().store().push([{ + type: 'update', + key: { id1: 1, id2: 'a' }, + data: { value: 30 }, + }]); + jest.runAllTimers(); + await Promise.resolve(); + + // only the pushed row is re-requested; the other row stays cached + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + expect(sendRequestSpy).toHaveBeenLastCalledWith({ + '{"id1":1,"id2":"a"}': { id1: 1, id2: 'a', value: 30 }, + }); + expect(instance.getAIColumnText('myAIColumn', { id1: 1, id2: 'a' })).toEqual('Response with value=30'); + expect(instance.getAIColumnText('myAIColumn', { id1: 2, id2: 'b' })).toEqual('Response with value=20'); + }); + }); }); 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 5f9ec1938617..a733cdd02e0f 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 @@ -6,6 +6,7 @@ import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import type { ColumnsController } from '../../columns_controller/m_columns_controller'; import type { DataController, HandleDataChangedArguments, UserData } from '../../data_controller/m_data_controller'; import { Controller } from '../../m_modules'; +import type { RowKey } from '../../m_types'; import gridCoreUtils from '../../m_utils'; import type { InternalRequestCallbacks } from '../types'; import { getAICommandColumnDefaultOptions, isAIColumnAutoMode, isPromptOption } from '../utils'; @@ -20,9 +21,9 @@ export class AIColumnController extends Controller { private dataSourceChangedHandler!: (e?: HandleDataChangedArguments) => void; - private storeUpdatedHandler!: (key: PropertyKey) => void; + private storeUpdatedHandler!: (key: RowKey) => void; - private storeRemovedHandler!: (key: PropertyKey) => void; + private storeRemovedHandler!: (key: RowKey) => void; private storeBeforePushHandler!: ({ changes }: { changes: DataChange[] }) => void; @@ -130,11 +131,11 @@ export class AIColumnController extends Controller { store.on('beforePush', this.storeBeforePushHandler); } - private handleStoreUpdated(key: PropertyKey): void { + private handleStoreUpdated(key: RowKey): void { this.clearAIColumnsByKey(key); } - private handleStoreRemoved(key: PropertyKey): void { + private handleStoreRemoved(key: RowKey): void { this.clearAIColumnsByKey(key); } @@ -164,7 +165,7 @@ export class AIColumnController extends Controller { return true; } - private clearAIColumnsByKey(key: PropertyKey): void { + private clearAIColumnsByKey(key: RowKey): void { const aiColumns = this.getAIColumns(); aiColumns.forEach((col) => { @@ -301,7 +302,7 @@ export class AIColumnController extends Controller { this.updateAICells(); } - public getAIColumnText(columnName: string, key: unknown): string | undefined { + public getAIColumnText(columnName: string, key: RowKey): string | undefined { return this.aiColumnIntegrationController.getAIColumnText(columnName, key); } diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_integration_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_integration_controller.ts index 048a3d86159b..2c1615933dfd 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_integration_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_integration_controller.ts @@ -10,6 +10,7 @@ import type { ColumnsController } from '../../columns_controller/m_columns_contr import type { DataController, UserData } from '../../data_controller/m_data_controller'; import type { ErrorHandlingController } from '../../error_handling/m_error_handling'; import { Controller } from '../../m_modules'; +import type { RowKey } from '../../m_types'; import type { InternalRequestCallbacks } from '../types'; import { getDataFromRowItems, isKeyMissingInData, reduceDataCachedKeys } from '../utils'; import { AIColumnCacheController } from './m_ai_column_cache_controller'; @@ -187,7 +188,7 @@ export class AIColumnIntegrationController extends Controller { this.errorHandlingController?.showToastError(message); } - public getAIColumnText(columnName: string, key: unknown): string | undefined { + public getAIColumnText(columnName: string, key: RowKey): string | undefined { return this.aiColumnCacheController.getCachedString(columnName, getKeyHash(key) as PropertyKey); } @@ -195,7 +196,7 @@ export class AIColumnIntegrationController extends Controller { this.aiColumnCacheController.clearCache(columnName); } - public clearAIColumnByKey(columnName: string, key: unknown): void { + public clearAIColumnByKey(columnName: string, key: RowKey): void { this.aiColumnCacheController.clearCacheByKey(columnName, getKeyHash(key) as PropertyKey); } From 067a3e6381274e41dc3dfab2deac6fd985d91d78 Mon Sep 17 00:00:00 2001 From: "anna.shakhova" <68295572+anna-shakhova@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:26:34 +0200 Subject: [PATCH 6/8] Grids: extend AI Column key-field guard (E1046) to compound keys --- .../__tests__/ai_column.api_handlers.test.ts | 38 ++++++++++++++ .../grids/grid_core/ai_column/utils.test.ts | 50 ++++++++++++++++++- .../grids/grid_core/ai_column/utils.ts | 10 ++-- 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.api_handlers.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.api_handlers.test.ts index 401ef300419f..f3231ecd8e55 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.api_handlers.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.api_handlers.test.ts @@ -423,5 +423,43 @@ describe('API Handlers', () => { expect(onDataErrorOccurred).toHaveBeenCalledTimes(1); expect(errors.Error).toHaveBeenCalledWith('E1046', 'id'); }); + + it('should throw E1046 and not send the request when the handler removes a compound key subfield', async () => { + const onDataErrorOccurred = jest.fn(); + const { instance } = await createDataGrid({ + dataSource: [ + { id1: 1, id2: 'a', value: 10 }, + { id1: 2, id2: 'b', value: 20 }, + ], + keyExpr: ['id1', 'id2'], + columns: [ + { dataField: 'id1' }, + { dataField: 'id2' }, + { dataField: 'value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myColumn', + ai: { + aiIntegration: columnAIIntegration, + mode: 'manual', + prompt: 'Test prompt', + }, + }, + ], + onAIColumnRequestCreating: (e) => { + const reduced = e.data.map((item) => ({ id1: item.id1, value: item.value })); + e.data.splice(0, e.data.length, ...reduced); + }, + onDataErrorOccurred, + }); + + instance.sendAIColumnRequest('myColumn'); + jest.advanceTimersByTime(10000); + + expect(columnSendRequestStarted).toHaveBeenCalledTimes(0); + expect(onDataErrorOccurred).toHaveBeenCalledTimes(1); + expect(errors.Error).toHaveBeenCalledWith('E1046', ['id1', 'id2']); + }); }); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.test.ts index a541d45002da..6ae8af3bb206 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.test.ts @@ -3,11 +3,12 @@ import { } from '@jest/globals'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; -import type { Item } from '../data_controller/m_data_controller'; +import type { Item, UserData } from '../data_controller/m_data_controller'; import { getDataFromRowItems, isAIColumnAutoMode, isEditorOptions, + isKeyMissingInData, isPopupOptions, isPromptOption, isRefreshOption, @@ -78,6 +79,52 @@ describe('reduceDataCachedKeys', () => { }); }); +describe('isKeyMissingInData', () => { + it('should return false when a primitive key is present in all rows', () => { + const data: UserData[] = [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ]; + expect(isKeyMissingInData(data, 'id')).toBe(false); + }); + + it('should return true when a primitive key is missing from a row', () => { + const data: UserData[] = [ + { id: 1, name: 'A' }, + { name: 'B' }, + ]; + expect(isKeyMissingInData(data, 'id')).toBe(true); + }); + + it('should return false when a primitive key is present but null or undefined', () => { + const data: UserData[] = [ + { id: null, name: 'A' }, + { id: undefined, name: 'B' }, + ]; + expect(isKeyMissingInData(data, 'id')).toBe(false); + }); + + it('should return false when all compound key fields are present in all rows', () => { + const data: UserData[] = [ + { id1: 1, id2: 'a' }, + { id1: 2, id2: 'b' }, + ]; + expect(isKeyMissingInData(data, ['id1', 'id2'])).toBe(false); + }); + + it('should return true when a compound key subfield is missing from a row', () => { + const data: UserData[] = [ + { id1: 1, id2: 'a' }, + { id1: 2 }, + ]; + expect(isKeyMissingInData(data, ['id1', 'id2'])).toBe(true); + }); + + it('should return false for empty data', () => { + expect(isKeyMissingInData([], 'id')).toBe(false); + }); +}); + describe('getDataFromRowItems', () => { it('should extract data rows correctly', () => { const items = [ @@ -197,6 +244,7 @@ describe('isPopupOptions', () => { })).toBe(false); }); }); + describe('isEditorOptions', () => { it('should return true for editorOptions option names', () => { expect(isEditorOptions('ai.editorOptions.width', 200)).toBe(true); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.ts index f0a1a0fbad00..c11546471a0f 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.ts @@ -37,13 +37,11 @@ export const isKeyMissingInData = ( data: UserData[], keyField: string | string[], ): boolean => { - if (typeof keyField !== 'string') { - // The key field should be a string for AI Column functionality. - // Return false to avoid unnecessary errors for compound keys. - return false; - } + const keyFields = Array.isArray(keyField) ? keyField : [keyField]; - return data.some((item) => !(keyField in item)); + return data.some( + (item) => keyFields.some((field) => !(field in item)), + ); }; export const isAIColumnAutoMode = (column: Column): boolean => column.type === 'ai' && (!column.ai?.mode || column.ai.mode === 'auto'); From eb2d900cb7c0caf59fac0281c07d553f1ffe0718 Mon Sep 17 00:00:00 2001 From: "anna.shakhova" <68295572+anna-shakhova@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:21:37 +0200 Subject: [PATCH 7/8] fix copilot comments --- .../__tests__/ai_column.api_handlers.test.ts | 11 ++- .../__tests__/ai_column.cache.test.ts | 72 +++++++++++++++++++ .../m_ai_column_integration_controller.ts | 2 +- .../grids/grid_core/ai_column/utils.test.ts | 16 +++++ .../grids/grid_core/ai_column/utils.ts | 6 +- 5 files changed, 99 insertions(+), 8 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.api_handlers.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.api_handlers.test.ts index f3231ecd8e55..3d6e05f07add 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.api_handlers.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.api_handlers.test.ts @@ -9,6 +9,7 @@ import { afterTest, beforeTest as baseBeforeTest, createDataGrid, + flushAsync, GRID_CONTAINER_ID, } from '../../__tests__/__mock__/helpers/utils'; @@ -410,14 +411,13 @@ describe('API Handlers', () => { }, ], onAIColumnRequestCreating: (e) => { - const reduced = e.data.map((item) => ({ name: item.name, value: item.value })); - e.data.splice(0, e.data.length, ...reduced); + e.data = e.data.map((item) => ({ name: item.name, value: item.value })); }, onDataErrorOccurred, }); instance.sendAIColumnRequest('myColumn'); - jest.advanceTimersByTime(10000); + await flushAsync(); expect(columnSendRequestStarted).toHaveBeenCalledTimes(0); expect(onDataErrorOccurred).toHaveBeenCalledTimes(1); @@ -448,14 +448,13 @@ describe('API Handlers', () => { }, ], onAIColumnRequestCreating: (e) => { - const reduced = e.data.map((item) => ({ id1: item.id1, value: item.value })); - e.data.splice(0, e.data.length, ...reduced); + e.data = e.data.map((item) => ({ id1: item.id1, value: item.value })); }, onDataErrorOccurred, }); instance.sendAIColumnRequest('myColumn'); - jest.advanceTimersByTime(10000); + await flushAsync(); expect(columnSendRequestStarted).toHaveBeenCalledTimes(0); expect(onDataErrorOccurred).toHaveBeenCalledTimes(1); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts index 61584c0d55d4..8605073811f1 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts @@ -368,6 +368,78 @@ describe('Cache', () => { }); }); + describe('when a handler replaces the data array', () => { + it('should look up the cache using the reassigned e.data, not the original page data', async () => { + let replaceData = false; + const aiIntegration = new AIIntegration({ + sendRequest(prompt): RequestResult { + sendRequestSpy(prompt.data?.data); + return { + promise: new Promise((resolve) => { + const result = {}; + Object.entries(prompt.data?.data).forEach(([key, value]) => { + result[key] = `Response ${(value as any).name}`; + }); + resolve(JSON.stringify(result)); + }), + abort: (): void => {}, + }; + }, + }); + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Name 1', value: 10 }, + { id: 2, name: 'Name 2', value: 20 }, + ], + keyExpr: 'id', + paging: { + pageSize: 1, + }, + columns: [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + { dataField: 'value', caption: 'Value' }, + { + type: 'ai', + caption: 'AI Column', + name: 'myColumn', + ai: { + aiIntegration, + prompt: 'Test prompt', + }, + }, + ], + onAIColumnRequestCreating: (e) => { + if (replaceData) { + // Handlers may replace e.data with a brand-new array, not only mutate it. + e.data = [ + { id: 1, name: 'Name 1', value: 10 }, + { id: 2, name: 'Name 2', value: 20 }, + ]; + } + }, + }); + + // Each page populates the cache for its single visible row. + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + + instance.option('paging.pageIndex', 1); + jest.runAllTimers(); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + + // The page now shows a single row, but the handler replaces e.data with the + // full dataset whose keys are all cached. The lookup must key off e.data, so + // no new request is sent. + replaceData = true; + instance.option('paging.pageIndex', 0); + jest.runAllTimers(); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + }); + }); + describe('common behavior', () => { it('should not cache empty responses', async () => { const aiIntegrationResult = (prompt): RequestResult => ({ diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_integration_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_integration_controller.ts index 2c1615933dfd..d8877c327ea3 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_integration_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/controllers/m_ai_column_integration_controller.ts @@ -143,7 +143,7 @@ export class AIColumnIntegrationController extends Controller { let cachedResponse: Record = {}; if (args.useCache) { - const keys = data.map((item) => this.getRowKeyHash(item)); + const keys = args.data.map((item) => this.getRowKeyHash(item)); cachedResponse = this.aiColumnCacheController.getCachedResponse(columnName, keys); } diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.test.ts index 6ae8af3bb206..7030eed22607 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.test.ts @@ -120,6 +120,22 @@ describe('isKeyMissingInData', () => { expect(isKeyMissingInData(data, ['id1', 'id2'])).toBe(true); }); + it('should return false when a function key expression resolves for all rows', () => { + const data: UserData[] = [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ]; + expect(isKeyMissingInData(data, (row) => row.id)).toBe(false); + }); + + it('should return true when a function key expression cannot resolve a row', () => { + const data: UserData[] = [ + { id: 1, name: 'A' }, + { name: 'B' }, + ]; + expect(isKeyMissingInData(data, (row) => row.id)).toBe(true); + }); + it('should return false for empty data', () => { expect(isKeyMissingInData([], 'id')).toBe(false); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.ts index c11546471a0f..e2c9216c96c0 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/utils.ts @@ -35,8 +35,12 @@ export const reduceDataCachedKeys = ( export const isKeyMissingInData = ( data: UserData[], - keyField: string | string[], + keyField: string | string[] | ((data: UserData) => unknown), ): boolean => { + if (typeof keyField === 'function') { + return data.some((item) => !isDefined(keyField(item))); + } + const keyFields = Array.isArray(keyField) ? keyField : [keyField]; return data.some( From 6d3b157fd5385b2568c72164d6f9de69306b733a Mon Sep 17 00:00:00 2001 From: "anna.shakhova" <68295572+anna-shakhova@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:23:40 +0200 Subject: [PATCH 8/8] merge compound and single key tests in matrix --- .../__tests__/ai_column.api_handlers.test.ts | 50 +---- .../__tests__/ai_column.cache.test.ts | 173 +++++------------- 2 files changed, 55 insertions(+), 168 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.api_handlers.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.api_handlers.test.ts index 3d6e05f07add..3e945866de08 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.api_handlers.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.api_handlers.test.ts @@ -387,51 +387,19 @@ describe('API Handlers', () => { expect(sendRequestDataSpy).toHaveBeenCalledTimes(3); }); - it('should throw E1046 and not send the request when the handler removes the key field', async () => { - const onDataErrorOccurred = jest.fn(); - const { instance } = await createDataGrid({ - dataSource: [ - { id: 1, name: 'Name 1', value: 10 }, - { id: 2, name: 'Name 2', value: 20 }, - ], - keyExpr: 'id', - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myColumn', - ai: { - aiIntegration: columnAIIntegration, - mode: 'manual', - prompt: 'Test prompt', - }, - }, - ], - onAIColumnRequestCreating: (e) => { - e.data = e.data.map((item) => ({ name: item.name, value: item.value })); - }, - onDataErrorOccurred, - }); - - instance.sendAIColumnRequest('myColumn'); - await flushAsync(); - - expect(columnSendRequestStarted).toHaveBeenCalledTimes(0); - expect(onDataErrorOccurred).toHaveBeenCalledTimes(1); - expect(errors.Error).toHaveBeenCalledWith('E1046', 'id'); - }); - - it('should throw E1046 and not send the request when the handler removes a compound key subfield', async () => { + it.each([ + { keyType: 'the key field', keyExpr: 'id1' }, + { keyType: 'a compound key subfield', keyExpr: ['id1', 'id2'] }, + ])('should throw E1046 and not send the request when the handler removes $keyType', async ({ + keyExpr, + }) => { const onDataErrorOccurred = jest.fn(); const { instance } = await createDataGrid({ dataSource: [ { id1: 1, id2: 'a', value: 10 }, { id1: 2, id2: 'b', value: 20 }, ], - keyExpr: ['id1', 'id2'], + keyExpr, columns: [ { dataField: 'id1' }, { dataField: 'id2' }, @@ -448,7 +416,7 @@ describe('API Handlers', () => { }, ], onAIColumnRequestCreating: (e) => { - e.data = e.data.map((item) => ({ id1: item.id1, value: item.value })); + e.data = e.data.map((item) => ({ id2: item.id2, value: item.value })); }, onDataErrorOccurred, }); @@ -458,7 +426,7 @@ describe('API Handlers', () => { expect(columnSendRequestStarted).toHaveBeenCalledTimes(0); expect(onDataErrorOccurred).toHaveBeenCalledTimes(1); - expect(errors.Error).toHaveBeenCalledWith('E1046', ['id1', 'id2']); + expect(errors.Error).toHaveBeenCalledWith('E1046', keyExpr); }); }); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts index 8605073811f1..53f225666b84 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts @@ -202,65 +202,22 @@ describe('Cache', () => { expect(instance.getAIColumnText('myColumn', 2)).toEqual('Response Name 2'); }); - it('should use cache with pagination in auto mode', async () => { - const aiIntegration = new AIIntegration({ - sendRequest(prompt): RequestResult { - sendRequestSpy(prompt.data?.data); - - return { - promise: new Promise((resolve) => { - const result = {}; - Object.entries(prompt.data?.data).forEach(([key, value]) => { - result[key] = `Response ${(value as any).name}`; - }); - resolve(JSON.stringify(result)); - }), - abort: (): void => {}, - }; - }, - }); - const { instance } = await createDataGrid({ - dataSource: [ - { id: 1, name: 'Name 1', value: 10 }, - { id: 2, name: 'Name 2', value: 20 }, - ], - keyExpr: 'id', - paging: { - pageSize: 1, - }, - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myColumn', - ai: { - aiIntegration, - prompt: 'Test prompt', - }, - }, - ], - }); - - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(1); - expect(sendRequestSpy).toHaveBeenCalledWith({ 1: { id: 1, name: 'Name 1', value: 10 } }); - - instance.option('paging.pageIndex', 1); - jest.runAllTimers(); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(2); - expect(sendRequestSpy).toHaveBeenCalledWith({ 2: { id: 2, name: 'Name 2', value: 20 } }); - - instance.option('paging.pageIndex', 0); - jest.runAllTimers(); - await Promise.resolve(); - expect(sendRequestSpy).toHaveBeenCalledTimes(2); - }); - - it('should use cache with pagination in auto mode (compound key)', async () => { + it.each([ + { + keyType: 'single key', + keyExpr: 'id1', + firstRequest: { 1: { id1: 1, id2: 'a', name: 'Name 1' } }, + secondRequest: { 2: { id1: 2, id2: 'b', name: 'Name 2' } }, + }, + { + keyType: 'compound key', + keyExpr: ['id1', 'id2'], + firstRequest: { '{"id1":1,"id2":"a"}': { id1: 1, id2: 'a', name: 'Name 1' } }, + secondRequest: { '{"id1":2,"id2":"b"}': { id1: 2, id2: 'b', name: 'Name 2' } }, + }, + ])('should use cache with pagination in auto mode ($keyType)', async ({ + keyExpr, firstRequest, secondRequest, + }) => { const aiIntegration = new AIIntegration({ sendRequest(prompt): RequestResult { sendRequestSpy(prompt.data?.data); @@ -281,12 +238,12 @@ describe('Cache', () => { { id1: 1, id2: 'a', name: 'Name 1' }, { id1: 2, id2: 'b', name: 'Name 2' }, ], - keyExpr: ['id1', 'id2'], + keyExpr, paging: { pageSize: 1, }, columns: [ - { dataField: 'id1', caption: 'ID1' }, + { dataField: 'id1', caption: 'ID' }, { dataField: 'id2', caption: 'ID2' }, { dataField: 'name', caption: 'Name' }, { @@ -303,13 +260,13 @@ describe('Cache', () => { await Promise.resolve(); expect(sendRequestSpy).toHaveBeenCalledTimes(1); - expect(sendRequestSpy).toHaveBeenCalledWith({ '{"id1":1,"id2":"a"}': { id1: 1, id2: 'a', name: 'Name 1' } }); + expect(sendRequestSpy).toHaveBeenCalledWith(firstRequest); instance.option('paging.pageIndex', 1); jest.runAllTimers(); await Promise.resolve(); expect(sendRequestSpy).toHaveBeenCalledTimes(2); - expect(sendRequestSpy).toHaveBeenCalledWith({ '{"id1":2,"id2":"b"}': { id1: 2, id2: 'b', name: 'Name 2' } }); + expect(sendRequestSpy).toHaveBeenCalledWith(secondRequest); instance.option('paging.pageIndex', 0); jest.runAllTimers(); @@ -669,63 +626,25 @@ describe('Cache', () => { }); }); - describe('when data is updated via Push API', () => { - it('should clear cached data and send a prompt request', async () => { - const aiIntegration = new AIIntegration({ - sendRequest(prompt: RequestParams): RequestResult { - sendRequestSpy(prompt.data?.data); - - return { - promise: new Promise((resolve) => { - resolve(`{"1":"Response with value=${prompt.data?.data[1].value}"}`); - }), - abort: (): void => {}, - }; - }, - }); - const { instance } = await createDataGrid({ - dataSource: [ - { id: 1, name: 'Name 1', value: 10 }, - ], - editing: { - mode: 'batch', - allowUpdating: true, - }, - columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, - { - type: 'ai', - caption: 'AI Column', - name: 'myAIColumn', - ai: { - aiIntegration, - prompt: 'Initial prompt', - }, - }, - ], - }); - - expect(sendRequestSpy).toHaveBeenCalledTimes(1); - expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=10'); - - instance.getDataSource().store().push([{ - type: 'update', - key: 1, - data: { value: 20 }, - }]); - jest.runAllTimers(); - await Promise.resolve(); - - expect(sendRequestSpy).toHaveBeenCalledTimes(2); - expect(sendRequestSpy).toHaveBeenLastCalledWith({ 1: { id: 1, name: 'Name 1', value: 20 } }); - expect(instance.getAIColumnText('myAIColumn', 1)).toEqual('Response with value=20'); - }); - }); - - describe('when a compound-key row is updated via Push API', () => { - it('should clear cached data for the correct row and send a prompt request', async () => { + describe('when a row is updated via Push API', () => { + it.each([ + { + keyType: 'single key', + keyExpr: 'id1', + row1Key: 1, + row2Key: 2, + expectedRequestKey: 1, + }, + { + keyType: 'compound key', + keyExpr: ['id1', 'id2'], + row1Key: { id1: 1, id2: 'a' }, + row2Key: { id1: 2, id2: 'b' }, + expectedRequestKey: '{"id1":1,"id2":"a"}', + }, + ])('should clear cached data for the pushed row only and send a prompt request ($keyType)', async ({ + keyExpr, row1Key, row2Key, expectedRequestKey, + }) => { const aiIntegration = new AIIntegration({ sendRequest(prompt: RequestParams): RequestResult { sendRequestSpy(prompt.data?.data); @@ -747,7 +666,7 @@ describe('Cache', () => { { id1: 1, id2: 'a', value: 10 }, { id1: 2, id2: 'b', value: 20 }, ], - keyExpr: ['id1', 'id2'], + keyExpr, columns: [ { dataField: 'id1' }, { dataField: 'id2' }, @@ -765,12 +684,12 @@ describe('Cache', () => { }); expect(sendRequestSpy).toHaveBeenCalledTimes(1); - expect(instance.getAIColumnText('myAIColumn', { id1: 1, id2: 'a' })).toEqual('Response with value=10'); - expect(instance.getAIColumnText('myAIColumn', { id1: 2, id2: 'b' })).toEqual('Response with value=20'); + expect(instance.getAIColumnText('myAIColumn', row1Key)).toEqual('Response with value=10'); + expect(instance.getAIColumnText('myAIColumn', row2Key)).toEqual('Response with value=20'); instance.getDataSource().store().push([{ type: 'update', - key: { id1: 1, id2: 'a' }, + key: row1Key, data: { value: 30 }, }]); jest.runAllTimers(); @@ -779,10 +698,10 @@ describe('Cache', () => { // only the pushed row is re-requested; the other row stays cached expect(sendRequestSpy).toHaveBeenCalledTimes(2); expect(sendRequestSpy).toHaveBeenLastCalledWith({ - '{"id1":1,"id2":"a"}': { id1: 1, id2: 'a', value: 30 }, + [expectedRequestKey]: { id1: 1, id2: 'a', value: 30 }, }); - expect(instance.getAIColumnText('myAIColumn', { id1: 1, id2: 'a' })).toEqual('Response with value=30'); - expect(instance.getAIColumnText('myAIColumn', { id1: 2, id2: 'b' })).toEqual('Response with value=20'); + expect(instance.getAIColumnText('myAIColumn', row1Key)).toEqual('Response with value=30'); + expect(instance.getAIColumnText('myAIColumn', row2Key)).toEqual('Response with value=20'); }); }); });