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..e36a5e1fb173 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.ai_data.test.ts @@ -0,0 +1,398 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +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'; +import { AIIntegration } from '@ts/core/ai_integration/core/ai_integration'; +import ArrayStore from '@ts/data/m_array_store'; + +import { + afterTest, + beforeTest as baseBeforeTest, + createDataGrid, + flushAsync, +} 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(), + ], + ); + }); + }); + + 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/__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..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 @@ -9,6 +9,7 @@ import { afterTest, beforeTest as baseBeforeTest, createDataGrid, + flushAsync, GRID_CONTAINER_ID, } from '../../__tests__/__mock__/helpers/utils'; @@ -386,18 +387,23 @@ describe('API Handlers', () => { expect(sendRequestDataSpy).toHaveBeenCalledTimes(3); }); - it('should throw E1046 and not send the request when the handler removes the key field', 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: [ - { id: 1, name: 'Name 1', value: 10 }, - { id: 2, name: 'Name 2', value: 20 }, + { id1: 1, id2: 'a', value: 10 }, + { id1: 2, id2: 'b', value: 20 }, ], - keyExpr: 'id', + keyExpr, columns: [ - { dataField: 'id', caption: 'ID' }, - { dataField: 'name', caption: 'Name' }, - { dataField: 'value', caption: 'Value' }, + { dataField: 'id1' }, + { dataField: 'id2' }, + { dataField: 'value' }, { type: 'ai', caption: 'AI Column', @@ -410,18 +416,17 @@ 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) => ({ id2: item.id2, value: item.value })); }, onDataErrorOccurred, }); instance.sendAIColumnRequest('myColumn'); - jest.advanceTimersByTime(10000); + await flushAsync(); expect(columnSendRequestStarted).toHaveBeenCalledTimes(0); expect(onDataErrorOccurred).toHaveBeenCalledTimes(1); - expect(errors.Error).toHaveBeenCalledWith('E1046', 'id'); + 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 new file mode 100644 index 000000000000..53f225666b84 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_column/__tests__/ai_column.cache.test.ts @@ -0,0 +1,707 @@ +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.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); + 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, + paging: { + pageSize: 1, + }, + columns: [ + { dataField: 'id1', caption: 'ID' }, + { 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(firstRequest); + + instance.option('paging.pageIndex', 1); + jest.runAllTimers(); + await Promise.resolve(); + expect(sendRequestSpy).toHaveBeenCalledTimes(2); + expect(sendRequestSpy).toHaveBeenCalledWith(secondRequest); + + 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('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 => ({ + 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 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); + + 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, + 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', row1Key)).toEqual('Response with value=10'); + expect(instance.getAIColumnText('myAIColumn', row2Key)).toEqual('Response with value=20'); + + instance.getDataSource().store().push([{ + type: 'update', + key: row1Key, + 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({ + [expectedRequestKey]: { id1: 1, id2: 'a', value: 30 }, + }); + expect(instance.getAIColumnText('myAIColumn', row1Key)).toEqual('Response with value=30'); + expect(instance.getAIColumnText('myAIColumn', row2Key)).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 ce845362f310..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 @@ -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, @@ -3446,900 +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('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); 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..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,8 +302,8 @@ export class AIColumnController extends Controller { this.updateAICells(); } - public getAIColumnText(columnName: string, key: unknown): string | undefined { - return this.aiColumnIntegrationController.getAIColumnText(columnName, key as PropertyKey); + public getAIColumnText(columnName: string, key: RowKey): string | undefined { + 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..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 @@ -3,12 +3,14 @@ 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 { RowKey } from '../../m_types'; import type { InternalRequestCallbacks } from '../types'; import { getDataFromRowItems, isKeyMissingInData, reduceDataCachedKeys } from '../utils'; import { AIColumnCacheController } from './m_ai_column_cache_controller'; @@ -74,6 +76,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 +143,15 @@ export class AIColumnIntegrationController extends Controller { let cachedResponse: Record = {}; if (args.useCache) { - const keys = data.map((item) => item[keyField] as PropertyKey); + const keys = args.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 +188,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: RowKey): 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: RowKey): 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..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 @@ -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, @@ -25,7 +26,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 +39,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 +58,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,13 +72,75 @@ 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' }, }); }); }); +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 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); + }); +}); + describe('getDataFromRowItems', () => { it('should extract data rows correctly', () => { const items = [ @@ -197,6 +260,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 849c0d9a21de..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 @@ -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; } @@ -35,15 +35,17 @@ export const reduceDataCachedKeys = ( export const isKeyMissingInData = ( data: UserData[], - keyField: string | string[], + keyField: string | string[] | ((data: UserData) => unknown), ): 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; + if (typeof keyField === 'function') { + return data.some((item) => !isDefined(keyField(item))); } - return data.some((item) => !(keyField in item)); + const keyFields = Array.isArray(keyField) ? keyField : [keyField]; + + 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');