From be74cd4dbec2ef364f43fcb452b7193655f5ab59 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 7 May 2026 23:18:27 +0400 Subject: [PATCH 1/4] remove temporary spec actualize tests for AIAssistantIntegrationController implement buildContext method pass response schema to AI Integration from AI controller fix option types and add todos to move them to d.ts implement getCustomizedResponseTitle method add a command list to both controllers temporarily add the specification add getGridCommandList method to be able to override it in a simple way Replace CommandResults type with simple CommandResult[] and remove it from types as redundant --- .../data_grid/ai_assistant/ai_assistant.ts | 4 +- .../ai_assistant/ai_assistant_controller.ts | 17 + .../data_grid/ai_assistant/commands/index.ts | 21 + ...integration_controller.integration.test.ts | 466 +++++++++++++++++- .../ai_assistant/ai_assistant_controller.ts | 162 ++++-- .../ai_assistant_integration_controller.ts | 141 +++++- .../grid_core/ai_assistant/commands/index.ts | 78 +++ .../grid_core/ai_assistant/grid_commands.ts | 2 - .../grids/grid_core/ai_assistant/types.ts | 63 ++- .../grids/grid_core/ai_chat/ai_chat.test.ts | 6 +- .../grids/grid_core/ai_chat/ai_chat.ts | 6 +- .../grids/grid_core/ai_chat/types.ts | 4 +- 12 files changed, 875 insertions(+), 95 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_controller.ts create mode 100644 packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/index.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant.ts index a90e3f77231c..5561c078a05c 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant.ts @@ -1,9 +1,9 @@ import messageLocalization from '@js/common/core/localization/message'; -import { AIAssistantController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_controller'; import { AIAssistantView } from '@ts/grids/grid_core/ai_assistant/ai_assistant_view'; import { AIAssistantViewController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_view_controller'; import gridCore from '../m_core'; +import { DataGridAIAssistantController } from './ai_assistant_controller'; gridCore.registerModule('aiAssistant', { defaultOptions() { @@ -15,7 +15,7 @@ gridCore.registerModule('aiAssistant', { }; }, controllers: { - aiAssistant: AIAssistantController, + aiAssistant: DataGridAIAssistantController, aiAssistantViewController: AIAssistantViewController, }, views: { diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_controller.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_controller.ts new file mode 100644 index 000000000000..3a6b72f8b476 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_controller.ts @@ -0,0 +1,17 @@ +import { AIAssistantController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_controller'; +import type { GridCommand, GridExtraContextOption } from '@ts/grids/grid_core/ai_assistant/types'; + +import { dataGridCommands } from './commands'; + +export class DataGridAIAssistantController extends AIAssistantController { + protected getGridCommandList(): GridCommand[] { + return dataGridCommands; + } + + protected getGridExtraContext(): GridExtraContextOption | null { + return { + grid: ['summary'], + column: ['groupIndex'], + }; + } +} diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/index.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/index.ts new file mode 100644 index 000000000000..eab8f2829946 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/index.ts @@ -0,0 +1,21 @@ +import commands, { commandsCore } from '@ts/grids/grid_core/ai_assistant/commands'; +import type { GridCommand } from '@ts/grids/grid_core/ai_assistant/types'; + +import { clearGroupingCommand, groupingCommand } from './grouping'; +import { clearSummaryCommand, summaryCommand } from './summary'; + +export const dataGridCommands = [ + ...commandsCore, + groupingCommand, + clearGroupingCommand, + summaryCommand, + clearSummaryCommand, +] as GridCommand[]; + +export default { + ...commands, + groupingCommand, + clearGroupingCommand, + summaryCommand, + clearSummaryCommand, +}; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts index 3f5ba0087f54..c959187ff790 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts @@ -22,6 +22,13 @@ import { createDataGrid, } from '../../__tests__/__mock__/helpers/utils'; import { AIAssistantIntegrationController } from '../ai_assistant_integration_controller'; +import type { GridExtraContextOption, JsonSchema } from '../types'; + +const STUB_SCHEMA: JsonSchema = { type: 'object' }; +const EXTRA_CONTEXT: GridExtraContextOption = { + grid: ['summary'], + column: ['groupIndex'], +}; interface SendRequestResult { promise: Promise; @@ -98,7 +105,7 @@ describe('AIAssistantIntegrationController', () => { it('should log E1068', async () => { const controller = await createController({}); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); expect(errors.log).toHaveBeenCalledWith('E1068'); }); @@ -111,7 +118,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); expect(aiIntegration.executeGridAssistant) .toHaveBeenCalledTimes(1); @@ -126,7 +133,7 @@ describe('AIAssistantIntegrationController', () => { aiIntegration, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); expect(aiIntegration.executeGridAssistant) .toHaveBeenCalledTimes(1); @@ -143,7 +150,7 @@ describe('AIAssistantIntegrationController', () => { aiIntegration: gridAI, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); expect(assistantAI.executeGridAssistant) .toHaveBeenCalledTimes(1); @@ -164,12 +171,29 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name ascending'); + controller.sendRequest('Sort by name ascending', STUB_SCHEMA, EXTRA_CONTEXT); expect(capturedParams.text).toBe('Sort by name ascending'); expect(capturedParams.context).toBeDefined(); }); + it('should pass responseSchema to executeGridAssistant', async () => { + let capturedParams: Record = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as Record; + }); + + const customSchema: JsonSchema = { type: 'object', properties: { action: { type: 'string' } } }; + + const controller = await createController({ + aiAssistant: { enabled: true, aiIntegration }, + }); + + controller.sendRequest('Sort by name', customSchema, EXTRA_CONTEXT); + + expect(capturedParams.responseSchema).toEqual(customSchema); + }); + it('should abort previous request when sending new one', async () => { const abortSpy = jest.fn(); const aiIntegration = createMockAIIntegration(); @@ -182,12 +206,32 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); expect(abortSpy).not.toHaveBeenCalled(); - controller.sendRequest('Sort by id'); + controller.sendRequest('Sort by id', STUB_SCHEMA, EXTRA_CONTEXT); expect(abortSpy).toHaveBeenCalledTimes(1); }); + + it('should allow sending a new request after previous one errored', async () => { + let capturedCallbacks: RequestCallbacks = {}; + const aiIntegration = createMockAIIntegration((_params, callbacks) => { + capturedCallbacks = callbacks; + }); + + const controller = await createController({ + aiAssistant: { enabled: true, aiIntegration }, + }); + + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + capturedCallbacks.onError?.(new Error('Network error')); + + expect(controller.isRequestAwaitingCompletion()).toBe(false); + + controller.sendRequest('Sort by id', STUB_SCHEMA, EXTRA_CONTEXT); + expect(controller.isRequestAwaitingCompletion()).toBe(true); + expect(aiIntegration.executeGridAssistant).toHaveBeenCalledTimes(2); + }); }); describe('abortRequest', () => { @@ -203,7 +247,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); expect(controller.isRequestAwaitingCompletion()).toBe(true); controller.abortRequest(); @@ -221,7 +265,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', { + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT, { onComplete: jest.fn(), onError: jest.fn(), onAbort, @@ -240,13 +284,13 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', { + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT, { onComplete: jest.fn(), onError: jest.fn(), onAbort, }); - controller.sendRequest('Sort by id'); + controller.sendRequest('Sort by id', STUB_SCHEMA, EXTRA_CONTEXT); expect(onAbort).toHaveBeenCalledTimes(1); }); @@ -258,7 +302,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); expect(() => { controller.abortRequest(); @@ -276,7 +320,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', { + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT, { onComplete: jest.fn(), onError: jest.fn(), onAbort, @@ -300,7 +344,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', { + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT, { onComplete: jest.fn(), onError: jest.fn(), onAbort, @@ -312,6 +356,34 @@ describe('AIAssistantIntegrationController', () => { }); }); + describe('onComplete after abort', () => { + it('should ignore onComplete callback triggered after abort', async () => { + let capturedCallbacks: RequestCallbacks = {}; + const onComplete = jest.fn(); + const aiIntegration = createMockAIIntegration((_params, callbacks) => { + capturedCallbacks = callbacks; + }); + + const controller = await createController({ + aiAssistant: { enabled: true, aiIntegration }, + }); + + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT, { + onComplete, + onError: jest.fn(), + }); + + controller.abortRequest(); + expect(controller.isRequestAwaitingCompletion()).toBe(false); + + capturedCallbacks.onComplete?.({ + actions: [{ name: 'sort', args: { column: 'Name' } }], + } as ExecuteGridAssistantCommandResult); + + expect(onComplete).not.toHaveBeenCalled(); + }); + }); + describe('dispose', () => { it('should abort request on dispose', async () => { const abortSpy = jest.fn(); @@ -325,7 +397,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); expect(controller.isRequestAwaitingCompletion()).toBe(true); expect(abortSpy).not.toHaveBeenCalled(); controller.dispose(); @@ -351,7 +423,7 @@ describe('AIAssistantIntegrationController', () => { onAIAssistantRequestCreating, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); expect(callOrder).toEqual([ 'onAIAssistantRequestCreating', @@ -368,7 +440,7 @@ describe('AIAssistantIntegrationController', () => { onAIAssistantRequestCreating, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); expect(onAIAssistantRequestCreating).toHaveBeenCalledWith( expect.objectContaining({ @@ -401,7 +473,7 @@ describe('AIAssistantIntegrationController', () => { }, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); expect(capturedProviderParams.prompt).toEqual( expect.objectContaining({ @@ -431,7 +503,7 @@ describe('AIAssistantIntegrationController', () => { }, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); expect(aiIntegration.executeGridAssistant) .not.toHaveBeenCalled(); @@ -452,7 +524,7 @@ describe('AIAssistantIntegrationController', () => { }, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); const context = capturedParams.context as Record; @@ -474,7 +546,7 @@ describe('AIAssistantIntegrationController', () => { }, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); const additional = capturedParams.additionalInfo as Record; @@ -488,11 +560,361 @@ describe('AIAssistantIntegrationController', () => { onAIAssistantRequestCreating, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); expect(onAIAssistantRequestCreating).not.toHaveBeenCalled(); expect(errors.log).toHaveBeenCalledWith('E1068'); }); }); }); + + describe('buildContext', () => { + it('should return all columns including hidden ones', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + columns: [ + { + dataField: 'id', caption: 'ID', dataType: 'number', visible: true, + }, + { + dataField: 'name', caption: 'Name', dataType: 'string', visible: false, + }, + ], + }); + + const context = controller.buildContext(EXTRA_CONTEXT); + + expect(context.columns).toHaveLength(2); + expect(context.columns[0].dataField).toBe('id'); + expect(context.columns[0].visible).toBe(true); + expect(context.columns[1].dataField).toBe('name'); + expect(context.columns[1].visible).toBe(false); + }); + + it('should include all listed properties for each column', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + columns: [ + { + dataField: 'id', + caption: 'ID', + dataType: 'number', + visible: true, + sortOrder: 'asc', + sortIndex: 0, + fixed: true, + fixedPosition: 'left', + width: 100, + }, + ], + }); + + const context = controller.buildContext(EXTRA_CONTEXT); + const column = context.columns[0]; + + expect(column).toEqual(expect.objectContaining({ + dataField: 'id', + caption: 'ID', + dataType: 'number', + visible: true, + sortOrder: 'asc', + sortIndex: 0, + fixed: true, + fixedPosition: 'left', + width: 100, + })); + expect('visibleIndex' in column).toBe(true); + expect('groupIndex' in column).toBe(true); + expect('filterValue' in column).toBe(true); + }); + + it('should only include the listed column properties', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + columns: [ + { + dataField: 'id', + caption: 'ID', + dataType: 'number', + allowSorting: true, + }, + ], + }); + + const context = controller.buildContext(EXTRA_CONTEXT); + const columnKeys = Object.keys(context.columns[0]); + const expectedKeys = [ + 'dataField', 'caption', 'dataType', 'visible', + 'sortOrder', 'sortIndex', 'groupIndex', 'filterValue', + 'fixed', 'fixedPosition', 'width', 'visibleIndex', + ]; + + expect(columnKeys.sort()).toEqual(expectedKeys.sort()); + }); + + it('should exclude command columns', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + selection: { mode: 'multiple' }, + columns: [ + { dataField: 'id', caption: 'ID', dataType: 'number' }, + { dataField: 'name', caption: 'Name', dataType: 'string' }, + ], + }); + + const context = controller.buildContext(EXTRA_CONTEXT); + + const hasCommandColumn = context.columns.some( + (col) => !col.dataField, + ); + expect(hasCommandColumn).toBe(false); + }); + + it('should reflect current paging state', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + dataSource: [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + { id: 3, name: 'C' }, + ], + paging: { pageSize: 2, pageIndex: 0 }, + }); + + const context = controller.buildContext(EXTRA_CONTEXT); + + expect(context.paging.pageIndex).toBe(0); + expect(context.paging.pageSize).toBe(2); + expect(context.paging.totalCount).toBe(3); + }); + + it('should return empty string for search text when not set', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + }); + + const context = controller.buildContext(EXTRA_CONTEXT); + + expect(context.search.searchText).toBe(''); + }); + + it('should reflect current search text', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + searchPanel: { visible: true, text: 'test search' }, + }); + + const context = controller.buildContext(EXTRA_CONTEXT); + + expect(context.search.searchText).toBe('test search'); + }); + + it('should return empty array for selection when no rows selected', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + selection: { mode: 'multiple' }, + }); + + const context = controller.buildContext(EXTRA_CONTEXT); + + expect(context.selection.selectedRowKeys).toEqual([]); + }); + + it('should reflect currently selected keys', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + dataSource: [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ], + selection: { mode: 'multiple' }, + selectedRowKeys: [1, 2], + }); + + const context = controller.buildContext(EXTRA_CONTEXT); + + expect(context.selection.selectedRowKeys).toEqual([1, 2]); + }); + + it('should return undefined summary items when no summary configured', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + }); + + const context = controller.buildContext(EXTRA_CONTEXT); + const { summary } = context; + + expect(summary).toBeDefined(); + expect(summary?.totalItems).toBeUndefined(); + expect(summary?.groupItems).toBeUndefined(); + }); + + it('should reflect current summary configuration', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + summary: { + totalItems: [ + { column: 'id', summaryType: 'count' }, + ], + groupItems: [ + { column: 'name', summaryType: 'count' }, + ], + }, + }); + + const context = controller.buildContext(EXTRA_CONTEXT); + const { summary } = context; + + expect(summary?.totalItems).toEqual([ + expect.objectContaining({ column: 'id', summaryType: 'count' }), + ]); + expect(summary?.groupItems).toEqual([ + expect.objectContaining({ column: 'name', summaryType: 'count' }), + ]); + }); + + it('should return null filterValue when not set', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + }); + + const context = controller.buildContext(EXTRA_CONTEXT); + + expect(context.filtering.filterValue).toBeNull(); + }); + + it('should reflect current filterValue', async () => { + const filterExpression = ['name', '=', 'Name 1']; + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + filterValue: filterExpression, + }); + + const context = controller.buildContext(EXTRA_CONTEXT); + + expect(context.filtering.filterValue).toEqual(filterExpression); + }); + + it('should update context after grid state changes', async () => { + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'B' }, + { id: 2, name: 'A' }, + ], + columns: [ + { dataField: 'id', caption: 'ID', dataType: 'number' }, + { dataField: 'name', caption: 'Name', dataType: 'string' }, + ], + aiIntegration: createMockAIIntegration(), + } as unknown as Properties); + + const controller = new AIAssistantIntegrationController(instance); + controller.init(); + + const contextBefore = controller.buildContext(EXTRA_CONTEXT); + const nameSortBefore = contextBefore.columns + .find((col) => col.dataField === 'name')?.sortOrder; + expect(nameSortBefore).toBeUndefined(); + + instance.columnOption('name', 'sortOrder', 'asc'); + jest.runAllTimers(); + + const contextAfter = controller.buildContext(EXTRA_CONTEXT); + const nameSortAfter = contextAfter.columns + .find((col) => col.dataField === 'name')?.sortOrder; + expect(nameSortAfter).toBe('asc'); + }); + + describe('without extraContext', () => { + it('should not include summary in context when extraContext is null', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + summary: { + totalItems: [{ column: 'id', summaryType: 'count' }], + }, + }); + + const context = controller.buildContext(null); + + expect(context.summary).toBeUndefined(); + }); + + it('should not include groupIndex in columns when extraContext is null', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + columns: [ + { dataField: 'id', caption: 'ID', dataType: 'number' }, + ], + }); + + const context = controller.buildContext(null); + const columnKeys = Object.keys(context.columns[0]); + + expect(columnKeys).not.toContain('groupIndex'); + }); + + it('should only include base column properties when extraContext is null', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + columns: [ + { dataField: 'id', caption: 'ID', dataType: 'number' }, + ], + }); + + const context = controller.buildContext(null); + const columnKeys = Object.keys(context.columns[0]); + const expectedKeys = [ + 'dataField', 'caption', 'dataType', 'visible', + 'sortOrder', 'sortIndex', 'filterValue', + 'fixed', 'fixedPosition', 'width', 'visibleIndex', + ]; + + expect(columnKeys.sort()).toEqual(expectedKeys.sort()); + }); + }); + + describe('with partial extraContext', () => { + it('should include summary but not groupIndex when only grid extra is provided', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + summary: { + totalItems: [{ column: 'id', summaryType: 'count' }], + }, + columns: [ + { dataField: 'id', caption: 'ID', dataType: 'number' }, + ], + }); + + const context = controller.buildContext({ grid: ['summary'], column: [] }); + const { summary } = context; + + expect(summary).toBeDefined(); + expect(summary?.totalItems).toEqual([ + expect.objectContaining({ column: 'id', summaryType: 'count' }), + ]); + + const columnKeys = Object.keys(context.columns[0]); + expect(columnKeys).not.toContain('groupIndex'); + }); + + it('should include groupIndex but not summary when only column extra is provided', async () => { + const controller = await createController({ + aiIntegration: createMockAIIntegration(), + summary: { + totalItems: [{ column: 'id', summaryType: 'count' }], + }, + columns: [ + { dataField: 'id', caption: 'ID', dataType: 'number' }, + ], + }); + + const context = controller.buildContext({ grid: [], column: ['groupIndex'] }); + + expect(context.summary).toBeUndefined(); + + const columnKeys = Object.keys(context.columns[0]); + expect(columnKeys).toContain('groupIndex'); + }); + }); + }); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts index e26caa466291..be3c020e5865 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts @@ -1,19 +1,28 @@ -import type { ExecuteGridAssistantCommandResult } from '@js/common/ai-integration'; +import type { + ExecuteGridAssistantAction, + ExecuteGridAssistantCommandResult, +} from '@js/common/ai-integration'; import messageLocalization from '@js/common/core/localization/message'; import { ArrayStore } from '@js/common/data'; import Guid from '@js/core/guid'; -import { isString } from '@js/core/utils/type'; +import { captionize } from '@js/core/utils/inflector'; +import { isFunction, isString } from '@js/core/utils/type'; import type { DataSourceLike } from '@js/data/data_source'; import type { Message } from '@js/ui/chat'; import { fromPromise } from '@ts/core/utils/m_deferred'; import { Controller } from '../m_modules'; import { AIAssistantIntegrationController } from './ai_assistant_integration_controller'; +import { commandsCore } from './commands'; import { AI_ASSISTANT_AUTHOR, AI_ASSISTANT_AUTHOR_ID, MessageStatus } from './const'; import { GridCommands } from './grid_commands'; import type { AIMessage, - CommandResults, + CommandResult, + CustomizeResponseText, + CustomizeResponseTitle, + GridCommand, + GridExtraContextOption, } from './types'; import { getMessageStatus, isAIMessage } from './utils'; @@ -26,9 +35,38 @@ export class AIAssistantController extends Controller { private processing = false; - // TODO: need to implement method for getting customized response title - private getCustomizedResponseTitle(): string { - return ''; + private getCustomizedResponseTitle( + status: MessageStatus.Success | MessageStatus.Failure, + commandNames: GridCommand['name'][], + ): string { + // TODO: remove type description, it should be got from d.ts + const customizeResponseTitle = this.option('aiAssistant.customizeResponseTitle') as CustomizeResponseTitle | undefined; + + // There shouldn't be an empty array here, but we need to handle it anyway. + if (!commandNames.length) { + return messageLocalization.format('dxDataGrid-aiAssistantErrorMessage'); + } + + if (customizeResponseTitle && isFunction(customizeResponseTitle)) { + // TODO: add type description to d.ts + return customizeResponseTitle(status, commandNames); + } + + if (commandNames.length === 1) { + return captionize(commandNames[0]); + } + + return [ + commandNames.slice(0, -1).map(captionize).join(', '), + captionize(commandNames.at(-1)), + ].join(' and '); + } + + private getCommandNames(actions: ExecuteGridAssistantAction[]): GridCommand['name'][] { + const commandNames = actions.map(({ name }) => name); + const uniqueCommandNameSet = new Set(commandNames); + + return Array.from(uniqueCommandNameSet); } private updateAIMessage(messageId: string, data: Partial): void { @@ -41,8 +79,13 @@ export class AIAssistantController extends Controller { ]); } - private processResponse(response: ExecuteGridAssistantCommandResult): Promise { - if (!response?.actions || !Array.isArray(response.actions)) { + private processResponse(response: ExecuteGridAssistantCommandResult): Promise { + if (this.gridCommands?.isExecuting()) { + // TODO: need to localize default error message if execution is in progress + return Promise.reject(new Error('Unexpected error')); + } + + if (!response?.actions || !Array.isArray(response.actions) || !response.actions.length) { // TODO: need to localize default error message when there are no commands return Promise.reject(new Error('Default error message')); } @@ -52,7 +95,8 @@ export class AIAssistantController extends Controller { return Promise.reject(new Error('Received invalid commands')); } - const customizeResponseText = this.option('aiAssistant.customizeResponseText'); + // TODO: add type description to d.ts + const customizeResponseText = this.option('aiAssistant.customizeResponseText') as CustomizeResponseText | undefined; return this.gridCommands?.executeCommands(response.actions, customizeResponseText) ?? Promise.reject(new Error('Grid commands not initialized')); @@ -83,11 +127,15 @@ export class AIAssistantController extends Controller { return aiMessage; } - private completeAIMessage(messageId: string, commands: CommandResults): void { + private completeAIMessage( + messageId: string, + commands: CommandResult[], + commandNames: GridCommand['name'][], + ): void { const messageStatus = getMessageStatus(commands); this.updateAIMessage(messageId, { - headerText: this.getCustomizedResponseTitle(), + headerText: this.getCustomizedResponseTitle(messageStatus, commandNames), commands, status: messageStatus, // WA to trigger status update, remove when dxChat supports @@ -127,43 +175,73 @@ export class AIAssistantController extends Controller { this.setProcessing(true); return new Promise((resolve, reject) => { - this.aiAssistantIntegrationController?.sendRequest(aiMessage.prompt, { - onComplete: (response: ExecuteGridAssistantCommandResult): void => { - fromPromise(this.processResponse(response)) - .done((commands: CommandResults) => { - this.completeAIMessage(aiMessage.id, commands); - this.setProcessing(false); - resolve(); - }) - .fail((errorMessage) => { - const error = errorMessage instanceof Error - ? errorMessage - : new Error(String(errorMessage)); - - this.failAIMessage(aiMessage.id, error); - this.setProcessing(false); - reject(error); - }); - }, - onError: (error: Error): void => { - this.failAIMessage(aiMessage.id, error); - this.setProcessing(false); - reject(error); - }, - onAbort: (): void => { - const error = new Error(messageLocalization.format('dxDataGrid-aiAssistantAbortMessage')); - - this.failAIMessage(aiMessage.id, error); - this.setProcessing(false); - reject(error); + const responseSchema = this.gridCommands?.buildResponseSchema(); + const extraContext = this.getGridExtraContext(); + + if (!responseSchema) { + // TODO: Change error message + const error = new Error('Grid commands not initialized'); + + this.failAIMessage(aiMessage.id, error); + this.setProcessing(false); + reject(error); + return; + } + + this.aiAssistantIntegrationController?.sendRequest( + aiMessage.prompt, + responseSchema, + extraContext, + { + onComplete: (response: ExecuteGridAssistantCommandResult): void => { + fromPromise(this.processResponse(response)) + .done((commands: CommandResult[]) => { + const commandNames = this.getCommandNames(response.actions); + + this.completeAIMessage(aiMessage.id, commands, commandNames); + this.setProcessing(false); + resolve(); + }) + .fail((errorMessage) => { + // TODO: Change error message + const error = errorMessage instanceof Error + ? errorMessage + : new Error(String(errorMessage)); + + this.failAIMessage(aiMessage.id, error); + this.setProcessing(false); + reject(error); + }); + }, + onError: (error: Error): void => { + // TODO: Change error message + this.failAIMessage(aiMessage.id, error); + this.setProcessing(false); + reject(error); + }, + onAbort: (): void => { + const error = new Error(messageLocalization.format('dxDataGrid-aiAssistantAbortMessage')); + + this.failAIMessage(aiMessage.id, error); + this.setProcessing(false); + reject(error); + }, }, - }); + ); }); } + protected getGridCommandList(): GridCommand[] { + return commandsCore; + } + + protected getGridExtraContext(): GridExtraContextOption | null { + return null; + } + public init(): void { // TODO: initialize default commands list when they are ready - this.gridCommands = new GridCommands(this.component, []); + this.gridCommands = new GridCommands(this.component, this.getGridCommandList()); this.messageStore = new ArrayStore({ key: 'id', }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_integration_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_integration_controller.ts index d719f8f8ff91..046b7eddb887 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_integration_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_integration_controller.ts @@ -4,9 +4,18 @@ import type { RequestCallbacks, } from '@js/common/ai-integration'; import errors from '@js/ui/widget/ui.errors'; +import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import { Controller } from '../m_modules'; -import type { AIAssistantRequestCallbacks } from './types'; +import type { + AIAssistantRequestCallbacks, + GridColumnContext, + GridColumnContextOptional, + GridContext, + GridContextOptional, + GridExtraContextOption, + JsonSchema, +} from './types'; export class AIAssistantIntegrationController extends Controller { private abort?: () => void; @@ -26,12 +35,13 @@ export class AIAssistantIntegrationController extends Controller { return gridAIIntegration; } - errors.log('E1068'); return null; } public sendRequest( text: string, + responseSchema: JsonSchema, + extraContext: GridExtraContextOption | null, callbacks?: AIAssistantRequestCallbacks, ): void { if (this.isRequestAwaitingCompletion()) { @@ -39,19 +49,25 @@ export class AIAssistantIntegrationController extends Controller { } const aiIntegration = this.getAIIntegration(); - if (!aiIntegration) { + + if (aiIntegration === null) { + errors.log('E1068'); return; } - const context = this.buildContext(); - const responseSchema = AIAssistantIntegrationController.buildResponseSchema(); - - const args = { - context, + const context = this.buildContext(extraContext); + const args: { + context: Record; + responseSchema: JsonSchema; + cancel: boolean; + additionalInfo: Record; + } = { + context: context as unknown as Record, responseSchema, cancel: false, - additionalInfo: {} as Record, + additionalInfo: {}, }; + this.executeAction('onAIAssistantRequestCreating', args); if (args.cancel) { @@ -110,13 +126,108 @@ export class AIAssistantIntegrationController extends Controller { this.abort = undefined; } - // TODO: implement buildContext with grid commands - private buildContext(): Record { - return {}; + public buildContext(extraContext: GridExtraContextOption | null): GridContext { + const dataController = this.getController('data'); + const selectedRowKeys = (this.option('selectedRowKeys') ?? []) as (string | number)[]; + const searchText = this.option('searchPanel.text') ?? ''; + const gridExtraContext = this.getGridExtraContext(extraContext?.grid); + + return { + columns: this.buildColumnsContext(extraContext?.column), + filtering: { + filterValue: this.option('filterValue'), + }, + paging: { + pageIndex: dataController.pageIndex(), + pageSize: dataController.pageSize(), + totalCount: dataController.totalCount(), + }, + search: { + searchText, + }, + selection: { + selectedRowKeys, + }, + ...gridExtraContext, + }; + } + + private buildColumnsContext( + extraContext?: GridExtraContextOption['column'], + ): GridColumnContext[] { + const columnsController = this.getController('columns'); + const allColumns: Column[] = columnsController.getColumns(); + + return allColumns + .filter((column) => !column.command) + .map((column): GridColumnContext => { + const gridColumnExtraContext = this.getGridColumnExtraContext(column, extraContext); + + return ({ + dataField: column.dataField, + caption: column.caption, + dataType: column.dataType, + visible: column.visible !== false, + sortOrder: column.sortOrder, + sortIndex: column.sortIndex, + filterValue: column.filterValue, + fixed: column.fixed, + fixedPosition: column.fixedPosition, + width: column.width, + visibleIndex: column.visibleIndex, + ...gridColumnExtraContext, + }); + }); + } + + private getGridExtraContext( + gridExtraContext?: GridExtraContextOption['grid'], + ): GridContextOptional | undefined { + if (!gridExtraContext?.length) { + return undefined; + } + + const context: GridContextOptional = {}; + + gridExtraContext.forEach((optionName) => { + switch (optionName) { + case 'summary': { + context.summary = { + totalItems: this.option('summary.totalItems'), + groupItems: this.option('summary.groupItems'), + skipEmptyValues: this.option('summary.skipEmptyValues'), + }; + break; + } + default: + break; + } + }); + + return context; } - // TODO: implement buildResponseSchema with grid commands - private static buildResponseSchema(): Record { - return {}; + private getGridColumnExtraContext( + column: Column, + gridColumnExtraContext?: GridExtraContextOption['column'], + ): GridColumnContextOptional | undefined { + if (!gridColumnExtraContext?.length) { + return undefined; + } + + const context: GridColumnContextOptional = {}; + + gridColumnExtraContext.forEach((optionName) => { + switch (optionName) { + case 'groupIndex': { + context.groupIndex = column.groupIndex; + break; + } + default: + break; + } + }); + + return context; } } diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts new file mode 100644 index 000000000000..098330eec2d4 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts @@ -0,0 +1,78 @@ +import type { GridCommand } from '../types'; +import { + columnsPinningCommand, + columnsReorderCommand, + columnsResizeCommand, + columnsVisibilityCommand, +} from './columns'; +import { + clearFilterCommand, + filterValueCommand, +} from './filtering'; +import { + focusRowByIndexCommand, + focusRowByKeyCommand, +} from './focus'; +import { + pageIndexCommand, + pageSizeCommand, + pagingCommand, +} from './paging'; +import { + searchingCommand, +} from './searching'; +import { + clearSelectionCommand, + deselectAllCommand, + selectAllCommand, + selectByIndexesCommand, + selectByKeysCommand, +} from './selection'; +import { + clearSortingCommand, + sortingCommand, +} from './sorting'; + +export const commandsCore = [ + columnsPinningCommand, + columnsReorderCommand, + columnsResizeCommand, + columnsVisibilityCommand, + clearFilterCommand, + filterValueCommand, + focusRowByIndexCommand, + focusRowByKeyCommand, + pageIndexCommand, + pageSizeCommand, + pagingCommand, + searchingCommand, + clearSelectionCommand, + deselectAllCommand, + selectAllCommand, + selectByIndexesCommand, + selectByKeysCommand, + clearSortingCommand, + sortingCommand, +] as GridCommand[]; + +export default { + columnsPinningCommand, + columnsReorderCommand, + columnsResizeCommand, + columnsVisibilityCommand, + clearFilterCommand, + filterValueCommand, + focusRowByIndexCommand, + focusRowByKeyCommand, + pageIndexCommand, + pageSizeCommand, + pagingCommand, + searchingCommand, + clearSelectionCommand, + deselectAllCommand, + selectAllCommand, + selectByIndexesCommand, + selectByKeysCommand, + clearSortingCommand, + sortingCommand, +}; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands.ts index 22753f9556cd..9dc0f0735bfc 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/grid_commands.ts @@ -21,7 +21,6 @@ import type { export class GridCommands { private readonly component: InternalGrid; - // TODO: specify type of command arguments when default commands are implemented private readonly commands: Map; private executing = false; @@ -142,7 +141,6 @@ export class GridCommands { } private async executeCommand( - // TODO: specify type when default commands are implemented command: GridCommand, args: Record, callbacks: CommandCallbacks, diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts index 1bd02dc4f457..3e00a56180f5 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts @@ -1,5 +1,8 @@ +import type { DataType, SortOrder } from '@js/common'; import type { RequestCallbacks } from '@js/common/ai-integration'; +import type { FixedPosition } from '@js/common/grids'; import type { Message } from '@js/ui/chat'; +import type { SummaryGroupItem, SummaryTotalItem } from '@js/ui/data_grid'; import type { InternalGrid } from '@ts/grids/grid_core/m_types'; import type { z, ZodObject, ZodRawShape } from 'zod'; import type { JsonSchema7Type } from 'zod-to-json-schema'; @@ -18,8 +21,6 @@ export interface CommandResult { message: string; } -export type CommandResults = CommandResult[]; - export interface CommandCallbacks { success: (message?: string) => CommandResult; failure: (message?: string) => CommandResult; @@ -56,20 +57,76 @@ export interface CommandMessages { failure: string; } +// TODO: move to d.ts export type CustomizeResponseText = ( commandName: string, commandArgs: Record, ) => Partial | undefined; +// TODO: move to d.ts +export type CustomizeResponseTitle = ( + status: MessageStatus.Success | MessageStatus.Failure, + commandNames: GridCommand['name'][], +) => string; + export type AIAssistantRequestCallbacks = RequestCallbacks & { onAbort?: () => void; }; +export interface GridColumnContextOptional { + groupIndex?: number; +} + +export interface GridColumnContext extends GridColumnContextOptional { + dataField: string | undefined; + caption: string | undefined; + dataType: DataType | undefined; + visible: boolean; + sortOrder: SortOrder | undefined; + sortIndex: number | undefined; + filterValue: string | number | boolean | null | undefined; + fixed: boolean | undefined; + fixedPosition: FixedPosition | undefined; + width: number | string | undefined; + visibleIndex: number | undefined; +} + +export interface GridContextOptional { + summary?: { + totalItems: SummaryTotalItem[] | undefined; + groupItems: SummaryGroupItem[] | undefined; + skipEmptyValues: SummaryGroupItem['skipEmptyValues'] | undefined; + }; +} + +export interface GridContext extends GridContextOptional { + columns: GridColumnContext[]; + filtering: { + filterValue: string | unknown[] | Function | null | undefined; + }; + paging: { + pageIndex: number; + pageSize: number; + totalCount: number; + }; + search: { + searchText: string; + }; + selection: { + selectedRowKeys: (string | number)[]; + }; +} + +export interface GridExtraContextOption { + grid: (keyof GridContextOptional)[]; + column: (keyof GridColumnContextOptional)[]; +} + export type AIMessage = Message & { id: string; status: MessageStatus; headerText: string; prompt: string; errorText?: string; - commands?: CommandResults; + commands?: CommandResult[]; }; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.test.ts index b5088a47a6e8..20bacddc4c4a 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.test.ts @@ -19,7 +19,7 @@ import { CLASSES, CLEAR_CHAT_ICON, DEFAULT_POPUP_OPTIONS, ERROR_ITEM_EMOJI, REGENERATE_ICON, SUCCESS_ITEM_EMOJI, } from './const'; -import type { AIChatOptions, CommandResults } from './types'; +import type { AIChatOptions, CommandResult } from './types'; const mockWidgetInstance = { option: jest.fn(), @@ -349,7 +349,7 @@ describe('AIChat', () => { const chatConfig = getChatConfig(); const container = document.createElement('div'); - const commands: CommandResults = [ + const commands: CommandResult[] = [ { status: 'success', message: 'Sorted Name in ascending order.' }, { status: 'success', message: 'Page size set to 15.' }, ]; @@ -373,7 +373,7 @@ describe('AIChat', () => { const chatConfig = getChatConfig(); const container = document.createElement('div'); - const commands: CommandResults = [ + const commands: CommandResult[] = [ { status: 'success', message: 'Sorted Name.' }, { status: 'failure', message: 'Failed to group.' }, { status: 'aborted', message: 'Aborted filter.' }, diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.ts index 12525d1d82e2..240e04edd501 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.ts @@ -27,9 +27,7 @@ import { DEFAULT_POPUP_OPTIONS, REGENERATE_ICON, } from './const'; -import type { - AIChatOptions, CommandResult, CommandResults, -} from './types'; +import type { AIChatOptions, CommandResult } from './types'; import { findMessageById, getCommandItemStyle, @@ -230,7 +228,7 @@ export class AIChat { private renderCommandList( $container: dxElementWrapper, - commands?: CommandResults, + commands?: CommandResult[], ): void { if (!commands?.length) { return; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/types.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/types.ts index b0f2f3a1ae8c..357f332455f8 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/types.ts @@ -2,10 +2,10 @@ import type { dxElementWrapper } from '@js/core/renderer'; import type { Properties as ChatProperties } from '@js/ui/chat'; import type { Properties as PopupProperties } from '@js/ui/popup'; -import type { AIMessage, CommandResult, CommandResults } from '../ai_assistant/types'; +import type { AIMessage, CommandResult } from '../ai_assistant/types'; import type { CreateComponent } from '../m_types'; -export type { CommandResult, CommandResults }; +export type { CommandResult }; export interface AIChatOptions { container: dxElementWrapper; From c8f094f1038cfc16ada14c384cf073b64e61e940 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 13 May 2026 04:06:27 +0400 Subject: [PATCH 2/4] move extra context to integration controller fix review remarks fix qunit stubs add missing methods in zod stub fix qunit tests using stub for zod and zod-to-json-schema packages try to fix qunit tests fix tests of AIAssistantController --- .../ai_assistant/ai_assistant_controller.ts | 19 +- .../ai_assistant_integration_controller.ts | 26 ++ .../data_grid/ai_assistant/commands/index.ts | 11 +- .../__tests__/ai_assistant_controller.test.ts | 156 +++---- ...integration_controller.integration.test.ts | 422 +++++++++--------- .../ai_assistant/ai_assistant_controller.ts | 46 +- .../ai_assistant_integration_controller.ts | 228 ++++------ .../grid_core/ai_assistant/commands/index.ts | 25 +- .../grids/grid_core/ai_assistant/types.ts | 54 +-- packages/devextreme/js/common/grids.d.ts | 5 + .../testing/helpers/stubs/zodStub.js | 25 ++ .../helpers/stubs/zodToJsonSchemaStub.js | 13 + .../devextreme/testing/runner/lib/pages.ts | 6 +- packages/devextreme/ts/dx.all.d.ts | 4 + 14 files changed, 479 insertions(+), 561 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_integration_controller.ts create mode 100644 packages/devextreme/testing/helpers/stubs/zodStub.js create mode 100644 packages/devextreme/testing/helpers/stubs/zodToJsonSchemaStub.js diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_controller.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_controller.ts index 3a6b72f8b476..29c4e4c53b0b 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_controller.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_controller.ts @@ -1,17 +1,18 @@ import { AIAssistantController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_controller'; -import type { GridCommand, GridExtraContextOption } from '@ts/grids/grid_core/ai_assistant/types'; +import type { GridCommand } from '@ts/grids/grid_core/ai_assistant/types'; -import { dataGridCommands } from './commands'; +import type { DataGridAIAssistantIntegrationController } from './ai_assistant_integration_controller'; +import { dataGridCommands } from './commands/index'; export class DataGridAIAssistantController extends AIAssistantController { + protected aiAssistantIntegrationController?: DataGridAIAssistantIntegrationController; + protected getGridCommandList(): GridCommand[] { - return dataGridCommands; - } + const coreCommands = super.getGridCommandList(); - protected getGridExtraContext(): GridExtraContextOption | null { - return { - grid: ['summary'], - column: ['groupIndex'], - }; + return [ + ...coreCommands, + ...dataGridCommands, + ]; } } diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_integration_controller.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_integration_controller.ts new file mode 100644 index 000000000000..0d58b8de2a92 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_integration_controller.ts @@ -0,0 +1,26 @@ +import { AIAssistantIntegrationController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_integration_controller'; +import type { GridContext } from '@ts/grids/grid_core/ai_assistant/types'; +import type { Column } from '@ts/grids/grid_core/columns_controller/types'; + +export class DataGridAIAssistantIntegrationController extends AIAssistantIntegrationController { + protected getGridExtraContext(): GridContext { + const context = super.getGridExtraContext(); + + context.summary = { + totalItems: this.option('summary.totalItems'), + groupItems: this.option('summary.groupItems'), + }; + + return context; + } + + protected getGridColumnExtraContext(column: Column): GridContext { + const context = super.getGridColumnExtraContext(column); + + context.summary = { + groupIndex: column.groupIndex, + }; + + return context; + } +} diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/index.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/index.ts index eab8f2829946..b33c7d3ab445 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/index.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/index.ts @@ -1,21 +1,12 @@ -import commands, { commandsCore } from '@ts/grids/grid_core/ai_assistant/commands'; import type { GridCommand } from '@ts/grids/grid_core/ai_assistant/types'; import { clearGroupingCommand, groupingCommand } from './grouping'; import { clearSummaryCommand, summaryCommand } from './summary'; export const dataGridCommands = [ - ...commandsCore, groupingCommand, clearGroupingCommand, summaryCommand, clearSummaryCommand, + // TODO: try to remove "as GridCommand[]" ] as GridCommand[]; - -export default { - ...commands, - groupingCommand, - clearGroupingCommand, - summaryCommand, - clearSummaryCommand, -}; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts index 3f8d78e93a9b..579a191661a5 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts @@ -5,35 +5,29 @@ import { it, jest, } from '@jest/globals'; -import type { ExecuteGridAssistantCommandResult, RequestCallbacks } from '@js/common/ai-integration'; +import type { ExecuteGridAssistantCommandResult } from '@js/common/ai-integration'; import type { ArrayStore } from '@js/common/data'; import type { Message } from '@js/ui/chat'; import type { InternalGrid } from '../../m_types'; import { AIAssistantController } from '../ai_assistant_controller'; +import { AIAssistantIntegrationController } from '../ai_assistant_integration_controller'; import { AI_ASSISTANT_AUTHOR, AI_ASSISTANT_AUTHOR_ID, MessageStatus, } from '../const'; import { GridCommands } from '../grid_commands'; -import type { AIMessage, CommandResult } from '../types'; +import type { AIAssistantRequestCallbacks, AIMessage, CommandResult } from '../types'; jest.mock('../grid_commands'); +jest.mock('../ai_assistant_integration_controller'); const MockedGridCommands = GridCommands as jest.MockedClass; +const MockedAIAssistantIntegrationController = AIAssistantIntegrationController as + jest.MockedClass; -let sendRequestCallbacks: RequestCallbacks = {}; - -const mockAIIntegration = { - executeGridAssistant: jest.fn(( - _params: unknown, - callbacks: RequestCallbacks, - ) => { - sendRequestCallbacks = callbacks; - return jest.fn(); - }), -}; +let sendRequestCallbacks: AIAssistantRequestCallbacks = {}; const createController = ( options: Record = {}, @@ -66,13 +60,33 @@ describe('AIAssistantController', () => { beforeEach(() => { jest.clearAllMocks(); - // TODO: Rework the tests using updated GridCommands implementation (MockedGridCommands.mockImplementation as jest.Mock).call( MockedGridCommands, () => ({ validate: jest.fn().mockReturnValue(true), executeCommands: jest.fn<() => Promise>().mockResolvedValue([{ status: 'success', message: 'sort' }]), abort: jest.fn(), + buildResponseSchema: jest.fn().mockReturnValue({ type: 'object' }), + isExecuting: jest.fn().mockReturnValue(false), + }), + ); + + (MockedAIAssistantIntegrationController.mockImplementation as jest.Mock).call( + MockedAIAssistantIntegrationController, + () => ({ + init: jest.fn(), + dispose: jest.fn(), + sendRequest: jest.fn(( + _text: string, + _responseSchema: unknown, + callbacks?: AIAssistantRequestCallbacks, + ) => { + sendRequestCallbacks = callbacks ?? {}; + }), + abortRequest: jest.fn(() => { + sendRequestCallbacks.onAbort?.(); + }), + isRequestAwaitingCompletion: jest.fn().mockReturnValue(false), }), ); }); @@ -90,9 +104,7 @@ describe('AIAssistantController', () => { describe('sendRequestToAI', () => { it('should create pending message in store', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const timestamp = '2026-04-16T10:00:00.000Z'; const expectedTimestamp = Date.parse(timestamp); @@ -118,6 +130,13 @@ describe('AIAssistantController', () => { }); it('should keep message as pending when AI integration is not configured', async () => { + // Make sendRequest not call any callbacks (simulating no AI integration) + const integrationInstance = MockedAIAssistantIntegrationController + .mock.results[0]?.value as { sendRequest: jest.Mock } | undefined; + if (integrationInstance) { + integrationInstance.sendRequest = jest.fn(); + } + const controller = createController(); // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -137,9 +156,7 @@ describe('AIAssistantController', () => { }); it('should complete message as success when command succeed', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.sendRequestToAI({ @@ -211,9 +228,7 @@ describe('AIAssistantController', () => { }); it('should fail message when onError callback is called', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const promise = controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, @@ -239,9 +254,7 @@ describe('AIAssistantController', () => { }); it('should fail message when response has no actions', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const promise = controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, @@ -271,9 +284,7 @@ describe('AIAssistantController', () => { }); it('should resolve promise when command succeeds', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const promise = controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, @@ -288,9 +299,7 @@ describe('AIAssistantController', () => { }); it('should reject promise when onError is called', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const promise = controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, @@ -304,9 +313,7 @@ describe('AIAssistantController', () => { }); it('should reject promise when response has no actions', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const promise = controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, @@ -320,9 +327,7 @@ describe('AIAssistantController', () => { }); it('should ignore second request while first request is still processing', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.sendRequestToAI({ @@ -340,13 +345,15 @@ describe('AIAssistantController', () => { const messages = await getStore(controller).load(); expect(messages).toHaveLength(1); - expect(mockAIIntegration.executeGridAssistant).toHaveBeenCalledTimes(1); + + const integrationInstance = MockedAIAssistantIntegrationController.mock.results[0].value as { + sendRequest: jest.Mock; + }; + expect(integrationInstance.sendRequest).toHaveBeenCalledTimes(1); }); it('should accept new request after previous request completes successfully', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const firstPromise = controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, @@ -368,13 +375,15 @@ describe('AIAssistantController', () => { const messages = await getStore(controller).load(); expect(messages).toHaveLength(2); - expect(mockAIIntegration.executeGridAssistant).toHaveBeenCalledTimes(2); + + const integrationInstance = MockedAIAssistantIntegrationController.mock.results[0].value as { + sendRequest: jest.Mock; + }; + expect(integrationInstance.sendRequest).toHaveBeenCalledTimes(2); }); it('should accept new request after previous request fails with error', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const firstPromise = controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, @@ -396,13 +405,15 @@ describe('AIAssistantController', () => { const messages = await getStore(controller).load(); expect(messages).toHaveLength(2); - expect(mockAIIntegration.executeGridAssistant).toHaveBeenCalledTimes(2); + + const integrationInstance = MockedAIAssistantIntegrationController.mock.results[0].value as { + sendRequest: jest.Mock; + }; + expect(integrationInstance.sendRequest).toHaveBeenCalledTimes(2); }); it('should accept new request after previous request is aborted', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const firstPromise = controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, @@ -424,15 +435,17 @@ describe('AIAssistantController', () => { const messages = await getStore(controller).load(); expect(messages).toHaveLength(2); - expect(mockAIIntegration.executeGridAssistant).toHaveBeenCalledTimes(2); + + const integrationInstance = MockedAIAssistantIntegrationController.mock.results[0].value as { + sendRequest: jest.Mock; + }; + expect(integrationInstance.sendRequest).toHaveBeenCalledTimes(2); }); }); describe('abortRequest', () => { it('should fail message with abort error when request is aborted', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const promise = controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, @@ -458,9 +471,7 @@ describe('AIAssistantController', () => { }); it('should call gridCommands.abort when request is aborted', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const promise = controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, @@ -481,9 +492,7 @@ describe('AIAssistantController', () => { describe('sendRequestToAI with AIMessage (regenerate)', () => { it('should reset message status to pending when AIMessage is passed', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const aiMessage: AIMessage = { id: 'assistant-123', @@ -515,9 +524,7 @@ describe('AIAssistantController', () => { }); it('should not create new message when AIMessage is passed', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const aiMessage: AIMessage = { id: 'assistant-123', @@ -541,9 +548,7 @@ describe('AIAssistantController', () => { }); it('should send request with original prompt from AIMessage', () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const aiMessage: AIMessage = { id: 'assistant-123', @@ -558,18 +563,19 @@ describe('AIAssistantController', () => { // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.sendRequestToAI(aiMessage); - expect(mockAIIntegration.executeGridAssistant).toHaveBeenCalledWith( - expect.objectContaining({ - text: 'Sort by Name column', - }), + const integrationInstance = MockedAIAssistantIntegrationController.mock.results[0].value as { + sendRequest: jest.Mock; + }; + + expect(integrationInstance.sendRequest).toHaveBeenCalledWith( + 'Sort by Name column', + expect.any(Object), expect.any(Object), ); }); it('should clear errorText and commands when regenerating', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const aiMessage: AIMessage = { id: 'assistant-123', @@ -599,9 +605,7 @@ describe('AIAssistantController', () => { }); it('should complete regenerated message as success when command succeed', async () => { - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); const aiMessage: AIMessage = { id: 'assistant-123', diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts index c959187ff790..72af1f8a0778 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts @@ -22,13 +22,9 @@ import { createDataGrid, } from '../../__tests__/__mock__/helpers/utils'; import { AIAssistantIntegrationController } from '../ai_assistant_integration_controller'; -import type { GridExtraContextOption, JsonSchema } from '../types'; +import type { GridContext, JsonSchema } from '../types'; const STUB_SCHEMA: JsonSchema = { type: 'object' }; -const EXTRA_CONTEXT: GridExtraContextOption = { - grid: ['summary'], - column: ['groupIndex'], -}; interface SendRequestResult { promise: Promise; @@ -105,7 +101,7 @@ describe('AIAssistantIntegrationController', () => { it('should log E1068', async () => { const controller = await createController({}); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(errors.log).toHaveBeenCalledWith('E1068'); }); @@ -118,7 +114,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(aiIntegration.executeGridAssistant) .toHaveBeenCalledTimes(1); @@ -133,7 +129,7 @@ describe('AIAssistantIntegrationController', () => { aiIntegration, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(aiIntegration.executeGridAssistant) .toHaveBeenCalledTimes(1); @@ -150,7 +146,7 @@ describe('AIAssistantIntegrationController', () => { aiIntegration: gridAI, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(assistantAI.executeGridAssistant) .toHaveBeenCalledTimes(1); @@ -162,25 +158,25 @@ describe('AIAssistantIntegrationController', () => { describe('sendRequest', () => { it('should pass text to executeGridAssistant', async () => { - let capturedParams: Record = {}; + let capturedParams: GridContext = {}; const aiIntegration = createMockAIIntegration((params) => { - capturedParams = params as Record; + capturedParams = params as GridContext; }); const controller = await createController({ aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name ascending', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name ascending', STUB_SCHEMA); expect(capturedParams.text).toBe('Sort by name ascending'); expect(capturedParams.context).toBeDefined(); }); it('should pass responseSchema to executeGridAssistant', async () => { - let capturedParams: Record = {}; + let capturedParams: GridContext = {}; const aiIntegration = createMockAIIntegration((params) => { - capturedParams = params as Record; + capturedParams = params as GridContext; }); const customSchema: JsonSchema = { type: 'object', properties: { action: { type: 'string' } } }; @@ -189,7 +185,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', customSchema, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', customSchema); expect(capturedParams.responseSchema).toEqual(customSchema); }); @@ -206,10 +202,10 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(abortSpy).not.toHaveBeenCalled(); - controller.sendRequest('Sort by id', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by id', STUB_SCHEMA); expect(abortSpy).toHaveBeenCalledTimes(1); }); @@ -223,12 +219,12 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); capturedCallbacks.onError?.(new Error('Network error')); expect(controller.isRequestAwaitingCompletion()).toBe(false); - controller.sendRequest('Sort by id', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by id', STUB_SCHEMA); expect(controller.isRequestAwaitingCompletion()).toBe(true); expect(aiIntegration.executeGridAssistant).toHaveBeenCalledTimes(2); }); @@ -247,7 +243,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(controller.isRequestAwaitingCompletion()).toBe(true); controller.abortRequest(); @@ -265,7 +261,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT, { + controller.sendRequest('Sort by name', STUB_SCHEMA, { onComplete: jest.fn(), onError: jest.fn(), onAbort, @@ -284,13 +280,13 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT, { + controller.sendRequest('Sort by name', STUB_SCHEMA, { onComplete: jest.fn(), onError: jest.fn(), onAbort, }); - controller.sendRequest('Sort by id', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by id', STUB_SCHEMA); expect(onAbort).toHaveBeenCalledTimes(1); }); @@ -302,7 +298,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(() => { controller.abortRequest(); @@ -320,7 +316,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT, { + controller.sendRequest('Sort by name', STUB_SCHEMA, { onComplete: jest.fn(), onError: jest.fn(), onAbort, @@ -344,7 +340,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT, { + controller.sendRequest('Sort by name', STUB_SCHEMA, { onComplete: jest.fn(), onError: jest.fn(), onAbort, @@ -368,7 +364,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT, { + controller.sendRequest('Sort by name', STUB_SCHEMA, { onComplete, onError: jest.fn(), }); @@ -397,7 +393,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(controller.isRequestAwaitingCompletion()).toBe(true); expect(abortSpy).not.toHaveBeenCalled(); controller.dispose(); @@ -423,7 +419,7 @@ describe('AIAssistantIntegrationController', () => { onAIAssistantRequestCreating, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(callOrder).toEqual([ 'onAIAssistantRequestCreating', @@ -440,7 +436,7 @@ describe('AIAssistantIntegrationController', () => { onAIAssistantRequestCreating, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(onAIAssistantRequestCreating).toHaveBeenCalledWith( expect.objectContaining({ @@ -467,13 +463,13 @@ describe('AIAssistantIntegrationController', () => { const controller = await createController({ aiAssistant: { enabled: true, aiIntegration }, onAIAssistantRequestCreating: ( - e: { additionalInfo: Record }, + e: { additionalInfo: GridContext }, ): void => { e.additionalInfo = { customKey: 'customValue' }; }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(capturedProviderParams.prompt).toEqual( expect.objectContaining({ @@ -503,52 +499,72 @@ describe('AIAssistantIntegrationController', () => { }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(aiIntegration.executeGridAssistant) .not.toHaveBeenCalled(); }); + it('should call onAbort when cancel is set to true', async () => { + const onAbort = jest.fn(); + const aiIntegration = createMockAIIntegration(); + + const controller = await createController({ + aiAssistant: { enabled: true, aiIntegration }, + onAIAssistantRequestCreating: (e: { cancel: boolean }): void => { + e.cancel = true; + }, + }); + + controller.sendRequest('Sort by name', STUB_SCHEMA, { + onComplete: jest.fn(), + onError: jest.fn(), + onAbort, + }); + + expect(onAbort).toHaveBeenCalledTimes(1); + }); + it('should pass modified context to executeGridAssistant', async () => { - let capturedParams: Record = {}; + let capturedParams: GridContext = {}; const aiIntegration = createMockAIIntegration((params) => { - capturedParams = params as Record; + capturedParams = params as GridContext; }); const controller = await createController({ aiAssistant: { enabled: true, aiIntegration }, onAIAssistantRequestCreating: ( - e: { context: Record }, + e: { context: GridContext }, ): void => { e.context.customField = 'custom value'; }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); - const context = capturedParams.context as Record; + const context = capturedParams.context as GridContext; expect(context.customField).toBe('custom value'); }); it('should pass additionalInfo to executeGridAssistant', async () => { - let capturedParams: Record = {}; + let capturedParams: GridContext = {}; const aiIntegration = createMockAIIntegration((params) => { - capturedParams = params as Record; + capturedParams = params as GridContext; }); const controller = await createController({ aiAssistant: { enabled: true, aiIntegration }, onAIAssistantRequestCreating: ( - e: { additionalInfo: Record }, + e: { additionalInfo: GridContext }, ): void => { e.additionalInfo = { customData: 'My custom data' }; }, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); - const additional = capturedParams.additionalInfo as Record; + const additional = capturedParams.additionalInfo as GridContext; expect(additional.customData).toBe('My custom data'); }); @@ -560,7 +576,7 @@ describe('AIAssistantIntegrationController', () => { onAIAssistantRequestCreating, }); - controller.sendRequest('Sort by name', STUB_SCHEMA, EXTRA_CONTEXT); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(onAIAssistantRequestCreating).not.toHaveBeenCalled(); expect(errors.log).toHaveBeenCalledWith('E1068'); @@ -568,10 +584,15 @@ describe('AIAssistantIntegrationController', () => { }); }); - describe('buildContext', () => { + describe('context building', () => { it('should return all columns including hidden ones', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + const controller = await createController({ - aiIntegration: createMockAIIntegration(), + aiIntegration, columns: [ { dataField: 'id', caption: 'ID', dataType: 'number', visible: true, @@ -582,18 +603,26 @@ describe('AIAssistantIntegrationController', () => { ], }); - const context = controller.buildContext(EXTRA_CONTEXT); + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const columns = context.columns as GridContext[]; - expect(context.columns).toHaveLength(2); - expect(context.columns[0].dataField).toBe('id'); - expect(context.columns[0].visible).toBe(true); - expect(context.columns[1].dataField).toBe('name'); - expect(context.columns[1].visible).toBe(false); + expect(columns).toHaveLength(2); + expect(columns[0].dataField).toBe('id'); + expect(columns[0].visible).toBe(true); + expect(columns[1].dataField).toBe('name'); + expect(columns[1].visible).toBe(false); }); it('should include all listed properties for each column', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + const controller = await createController({ - aiIntegration: createMockAIIntegration(), + aiIntegration, columns: [ { dataField: 'id', @@ -609,8 +638,10 @@ describe('AIAssistantIntegrationController', () => { ], }); - const context = controller.buildContext(EXTRA_CONTEXT); - const column = context.columns[0]; + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const column = (context.columns as GridContext[])[0]; expect(column).toEqual(expect.objectContaining({ dataField: 'id', @@ -624,13 +655,16 @@ describe('AIAssistantIntegrationController', () => { width: 100, })); expect('visibleIndex' in column).toBe(true); - expect('groupIndex' in column).toBe(true); - expect('filterValue' in column).toBe(true); }); it('should only include the listed column properties', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + const controller = await createController({ - aiIntegration: createMockAIIntegration(), + aiIntegration, columns: [ { dataField: 'id', @@ -641,11 +675,13 @@ describe('AIAssistantIntegrationController', () => { ], }); - const context = controller.buildContext(EXTRA_CONTEXT); - const columnKeys = Object.keys(context.columns[0]); + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const columnKeys = Object.keys((context.columns as GridContext[])[0]); const expectedKeys = [ 'dataField', 'caption', 'dataType', 'visible', - 'sortOrder', 'sortIndex', 'groupIndex', 'filterValue', + 'sortOrder', 'sortIndex', 'fixed', 'fixedPosition', 'width', 'visibleIndex', ]; @@ -653,8 +689,13 @@ describe('AIAssistantIntegrationController', () => { }); it('should exclude command columns', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + const controller = await createController({ - aiIntegration: createMockAIIntegration(), + aiIntegration, selection: { mode: 'multiple' }, columns: [ { dataField: 'id', caption: 'ID', dataType: 'number' }, @@ -662,17 +703,25 @@ describe('AIAssistantIntegrationController', () => { ], }); - const context = controller.buildContext(EXTRA_CONTEXT); + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const columns = context.columns as GridContext[]; - const hasCommandColumn = context.columns.some( + const hasCommandColumn = columns.some( (col) => !col.dataField, ); expect(hasCommandColumn).toBe(false); }); it('should reflect current paging state', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + const controller = await createController({ - aiIntegration: createMockAIIntegration(), + aiIntegration, dataSource: [ { id: 1, name: 'A' }, { id: 2, name: 'B' }, @@ -681,48 +730,80 @@ describe('AIAssistantIntegrationController', () => { paging: { pageSize: 2, pageIndex: 0 }, }); - const context = controller.buildContext(EXTRA_CONTEXT); + controller.sendRequest('test', STUB_SCHEMA); - expect(context.paging.pageIndex).toBe(0); - expect(context.paging.pageSize).toBe(2); - expect(context.paging.totalCount).toBe(3); + const context = capturedParams.context as GridContext; + const paging = context.paging as GridContext; + + expect(paging.pageIndex).toBe(0); + expect(paging.pageSize).toBe(2); + expect(paging.totalCount).toBe(3); }); it('should return empty string for search text when not set', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + const controller = await createController({ - aiIntegration: createMockAIIntegration(), + aiIntegration, }); - const context = controller.buildContext(EXTRA_CONTEXT); + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const search = context.search as GridContext; - expect(context.search.searchText).toBe(''); + expect(search.searchText).toBe(''); }); it('should reflect current search text', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + const controller = await createController({ - aiIntegration: createMockAIIntegration(), + aiIntegration, searchPanel: { visible: true, text: 'test search' }, }); - const context = controller.buildContext(EXTRA_CONTEXT); + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const search = context.search as GridContext; - expect(context.search.searchText).toBe('test search'); + expect(search.searchText).toBe('test search'); }); it('should return empty array for selection when no rows selected', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + const controller = await createController({ - aiIntegration: createMockAIIntegration(), + aiIntegration, selection: { mode: 'multiple' }, }); - const context = controller.buildContext(EXTRA_CONTEXT); + controller.sendRequest('test', STUB_SCHEMA); - expect(context.selection.selectedRowKeys).toEqual([]); + const context = capturedParams.context as GridContext; + const selection = context.selection as GridContext; + + expect(selection.selectedRowKeys).toEqual([]); }); it('should reflect currently selected keys', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + const controller = await createController({ - aiIntegration: createMockAIIntegration(), + aiIntegration, dataSource: [ { id: 1, name: 'A' }, { id: 2, name: 'B' }, @@ -731,71 +812,58 @@ describe('AIAssistantIntegrationController', () => { selectedRowKeys: [1, 2], }); - const context = controller.buildContext(EXTRA_CONTEXT); - - expect(context.selection.selectedRowKeys).toEqual([1, 2]); - }); - - it('should return undefined summary items when no summary configured', async () => { - const controller = await createController({ - aiIntegration: createMockAIIntegration(), - }); - - const context = controller.buildContext(EXTRA_CONTEXT); - const { summary } = context; + controller.sendRequest('test', STUB_SCHEMA); - expect(summary).toBeDefined(); - expect(summary?.totalItems).toBeUndefined(); - expect(summary?.groupItems).toBeUndefined(); - }); + const context = capturedParams.context as GridContext; + const selection = context.selection as GridContext; - it('should reflect current summary configuration', async () => { - const controller = await createController({ - aiIntegration: createMockAIIntegration(), - summary: { - totalItems: [ - { column: 'id', summaryType: 'count' }, - ], - groupItems: [ - { column: 'name', summaryType: 'count' }, - ], - }, - }); - - const context = controller.buildContext(EXTRA_CONTEXT); - const { summary } = context; - - expect(summary?.totalItems).toEqual([ - expect.objectContaining({ column: 'id', summaryType: 'count' }), - ]); - expect(summary?.groupItems).toEqual([ - expect.objectContaining({ column: 'name', summaryType: 'count' }), - ]); + expect(selection.selectedRowKeys).toEqual([1, 2]); }); it('should return null filterValue when not set', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + const controller = await createController({ - aiIntegration: createMockAIIntegration(), + aiIntegration, }); - const context = controller.buildContext(EXTRA_CONTEXT); + controller.sendRequest('test', STUB_SCHEMA); - expect(context.filtering.filterValue).toBeNull(); + const context = capturedParams.context as GridContext; + const filtering = context.filtering as GridContext; + + expect(filtering.filterValue).toBeNull(); }); it('should reflect current filterValue', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + const filterExpression = ['name', '=', 'Name 1']; const controller = await createController({ - aiIntegration: createMockAIIntegration(), + aiIntegration, filterValue: filterExpression, }); - const context = controller.buildContext(EXTRA_CONTEXT); + controller.sendRequest('test', STUB_SCHEMA); - expect(context.filtering.filterValue).toEqual(filterExpression); + const context = capturedParams.context as GridContext; + const filtering = context.filtering as GridContext; + + expect(filtering.filterValue).toEqual(filterExpression); }); it('should update context after grid state changes', async () => { + const capturedParamsList: GridContext[] = []; + const aiIntegration = createMockAIIntegration((params) => { + capturedParamsList.push(params as GridContext); + }); + const { instance } = await createDataGrid({ dataSource: [ { id: 1, name: 'B' }, @@ -805,116 +873,30 @@ describe('AIAssistantIntegrationController', () => { { dataField: 'id', caption: 'ID', dataType: 'number' }, { dataField: 'name', caption: 'Name', dataType: 'string' }, ], - aiIntegration: createMockAIIntegration(), + aiIntegration, } as unknown as Properties); const controller = new AIAssistantIntegrationController(instance); controller.init(); - const contextBefore = controller.buildContext(EXTRA_CONTEXT); - const nameSortBefore = contextBefore.columns + controller.sendRequest('test before', STUB_SCHEMA); + + const contextBefore = capturedParamsList[0].context as GridContext; + const columnsBefore = contextBefore.columns as GridContext[]; + const nameSortBefore = columnsBefore .find((col) => col.dataField === 'name')?.sortOrder; expect(nameSortBefore).toBeUndefined(); instance.columnOption('name', 'sortOrder', 'asc'); jest.runAllTimers(); - const contextAfter = controller.buildContext(EXTRA_CONTEXT); - const nameSortAfter = contextAfter.columns + controller.sendRequest('test after', STUB_SCHEMA); + + const contextAfter = capturedParamsList[1].context as GridContext; + const columnsAfter = contextAfter.columns as GridContext[]; + const nameSortAfter = columnsAfter .find((col) => col.dataField === 'name')?.sortOrder; expect(nameSortAfter).toBe('asc'); }); - - describe('without extraContext', () => { - it('should not include summary in context when extraContext is null', async () => { - const controller = await createController({ - aiIntegration: createMockAIIntegration(), - summary: { - totalItems: [{ column: 'id', summaryType: 'count' }], - }, - }); - - const context = controller.buildContext(null); - - expect(context.summary).toBeUndefined(); - }); - - it('should not include groupIndex in columns when extraContext is null', async () => { - const controller = await createController({ - aiIntegration: createMockAIIntegration(), - columns: [ - { dataField: 'id', caption: 'ID', dataType: 'number' }, - ], - }); - - const context = controller.buildContext(null); - const columnKeys = Object.keys(context.columns[0]); - - expect(columnKeys).not.toContain('groupIndex'); - }); - - it('should only include base column properties when extraContext is null', async () => { - const controller = await createController({ - aiIntegration: createMockAIIntegration(), - columns: [ - { dataField: 'id', caption: 'ID', dataType: 'number' }, - ], - }); - - const context = controller.buildContext(null); - const columnKeys = Object.keys(context.columns[0]); - const expectedKeys = [ - 'dataField', 'caption', 'dataType', 'visible', - 'sortOrder', 'sortIndex', 'filterValue', - 'fixed', 'fixedPosition', 'width', 'visibleIndex', - ]; - - expect(columnKeys.sort()).toEqual(expectedKeys.sort()); - }); - }); - - describe('with partial extraContext', () => { - it('should include summary but not groupIndex when only grid extra is provided', async () => { - const controller = await createController({ - aiIntegration: createMockAIIntegration(), - summary: { - totalItems: [{ column: 'id', summaryType: 'count' }], - }, - columns: [ - { dataField: 'id', caption: 'ID', dataType: 'number' }, - ], - }); - - const context = controller.buildContext({ grid: ['summary'], column: [] }); - const { summary } = context; - - expect(summary).toBeDefined(); - expect(summary?.totalItems).toEqual([ - expect.objectContaining({ column: 'id', summaryType: 'count' }), - ]); - - const columnKeys = Object.keys(context.columns[0]); - expect(columnKeys).not.toContain('groupIndex'); - }); - - it('should include groupIndex but not summary when only column extra is provided', async () => { - const controller = await createController({ - aiIntegration: createMockAIIntegration(), - summary: { - totalItems: [{ column: 'id', summaryType: 'count' }], - }, - columns: [ - { dataField: 'id', caption: 'ID', dataType: 'number' }, - ], - }); - - const context = controller.buildContext({ grid: [], column: ['groupIndex'] }); - - expect(context.summary).toBeUndefined(); - - const columnKeys = Object.keys(context.columns[0]); - expect(columnKeys).toContain('groupIndex'); - }); - }); }); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts index be3c020e5865..4c9b16e171a0 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts @@ -13,7 +13,7 @@ import { fromPromise } from '@ts/core/utils/m_deferred'; import { Controller } from '../m_modules'; import { AIAssistantIntegrationController } from './ai_assistant_integration_controller'; -import { commandsCore } from './commands'; +import { coreCommands } from './commands/index'; import { AI_ASSISTANT_AUTHOR, AI_ASSISTANT_AUTHOR_ID, MessageStatus } from './const'; import { GridCommands } from './grid_commands'; import type { @@ -22,7 +22,6 @@ import type { CustomizeResponseText, CustomizeResponseTitle, GridCommand, - GridExtraContextOption, } from './types'; import { getMessageStatus, isAIMessage } from './utils'; @@ -31,18 +30,17 @@ export class AIAssistantController extends Controller { private messageStore?: ArrayStore; - private aiAssistantIntegrationController?: AIAssistantIntegrationController; + protected aiAssistantIntegrationController?: AIAssistantIntegrationController; private processing = false; private getCustomizedResponseTitle( status: MessageStatus.Success | MessageStatus.Failure, - commandNames: GridCommand['name'][], + commandNames: string[], ): string { // TODO: remove type description, it should be got from d.ts const customizeResponseTitle = this.option('aiAssistant.customizeResponseTitle') as CustomizeResponseTitle | undefined; - // There shouldn't be an empty array here, but we need to handle it anyway. if (!commandNames.length) { return messageLocalization.format('dxDataGrid-aiAssistantErrorMessage'); } @@ -62,7 +60,7 @@ export class AIAssistantController extends Controller { ].join(' and '); } - private getCommandNames(actions: ExecuteGridAssistantAction[]): GridCommand['name'][] { + private getCommandNames(actions: ExecuteGridAssistantAction[]): string[] { const commandNames = actions.map(({ name }) => name); const uniqueCommandNameSet = new Set(commandNames); @@ -130,7 +128,7 @@ export class AIAssistantController extends Controller { private completeAIMessage( messageId: string, commands: CommandResult[], - commandNames: GridCommand['name'][], + commandNames: string[], ): void { const messageStatus = getMessageStatus(commands); @@ -171,19 +169,23 @@ export class AIAssistantController extends Controller { }); } - private sendRequestToAICore(aiMessage: AIMessage): Promise { + private withProcessing(promise: Promise): Promise { this.setProcessing(true); - return new Promise((resolve, reject) => { + return promise.finally(() => { + this.setProcessing(false); + }); + } + + private sendRequestToAICore(aiMessage: AIMessage): Promise { + return this.withProcessing(new Promise((resolve, reject) => { const responseSchema = this.gridCommands?.buildResponseSchema(); - const extraContext = this.getGridExtraContext(); if (!responseSchema) { // TODO: Change error message const error = new Error('Grid commands not initialized'); this.failAIMessage(aiMessage.id, error); - this.setProcessing(false); reject(error); return; } @@ -191,7 +193,6 @@ export class AIAssistantController extends Controller { this.aiAssistantIntegrationController?.sendRequest( aiMessage.prompt, responseSchema, - extraContext, { onComplete: (response: ExecuteGridAssistantCommandResult): void => { fromPromise(this.processResponse(response)) @@ -199,52 +200,41 @@ export class AIAssistantController extends Controller { const commandNames = this.getCommandNames(response.actions); this.completeAIMessage(aiMessage.id, commands, commandNames); - this.setProcessing(false); resolve(); }) .fail((errorMessage) => { - // TODO: Change error message + // TODO: Change error message const error = errorMessage instanceof Error ? errorMessage : new Error(String(errorMessage)); this.failAIMessage(aiMessage.id, error); - this.setProcessing(false); reject(error); }); }, onError: (error: Error): void => { - // TODO: Change error message + // TODO: Change error message this.failAIMessage(aiMessage.id, error); - this.setProcessing(false); reject(error); }, onAbort: (): void => { const error = new Error(messageLocalization.format('dxDataGrid-aiAssistantAbortMessage')); this.failAIMessage(aiMessage.id, error); - this.setProcessing(false); reject(error); }, }, ); - }); + })); } protected getGridCommandList(): GridCommand[] { - return commandsCore; - } - - protected getGridExtraContext(): GridExtraContextOption | null { - return null; + return [...coreCommands]; } public init(): void { - // TODO: initialize default commands list when they are ready this.gridCommands = new GridCommands(this.component, this.getGridCommandList()); - this.messageStore = new ArrayStore({ - key: 'id', - }); + this.messageStore = new ArrayStore({ key: 'id' }); this.aiAssistantIntegrationController = new AIAssistantIntegrationController(this.component); this.aiAssistantIntegrationController.init(); } diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_integration_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_integration_controller.ts index 046b7eddb887..2d878689ffdd 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_integration_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_integration_controller.ts @@ -9,17 +9,97 @@ import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import { Controller } from '../m_modules'; import type { AIAssistantRequestCallbacks, - GridColumnContext, - GridColumnContextOptional, GridContext, - GridContextOptional, - GridExtraContextOption, JsonSchema, } from './types'; export class AIAssistantIntegrationController extends Controller { private abort?: () => void; + private getAICommandCallbacks( + callbacks?: RequestCallbacks, + ): RequestCallbacks { + return { + onComplete: (finalResponse: ExecuteGridAssistantCommandResult): void => { + if (!this.isRequestAwaitingCompletion()) { + return; + } + this.processCommandCompletion(); + callbacks?.onComplete?.(finalResponse); + }, + onError: (error: Error): void => { + this.processCommandCompletion(); + callbacks?.onError?.(error); + }, + }; + } + + private processCommandCompletion(): void { + this.abort = undefined; + } + + private buildContext(): GridContext { + const dataController = this.getController('data'); + const gridExtraContext = this.getGridExtraContext(); + const keyExpr = this.option('keyExpr') ?? dataController.getDataSource()?.store()?.key(); + + return { + keyExpr, + columns: this.buildColumnsContext(), + filtering: { + filterValue: this.option('filterValue'), + }, + paging: { + pageIndex: dataController.pageIndex(), + pageSize: dataController.pageSize(), + totalCount: dataController.totalCount(), + }, + search: { + searchText: this.option('searchPanel.text') ?? '', + }, + selection: { + selectedRowKeys: this.option('selectedRowKeys') ?? [], + mode: this.option('selection.mode'), + selectAllMode: this.option('selection.selectAllMode'), + }, + ...gridExtraContext, + } as GridContext; + } + + private buildColumnsContext(): GridContext[] { + const columnsController = this.getController('columns'); + const allColumns: Column[] = columnsController.getColumns(); + + return allColumns + .filter((column) => !column.command) + .map((column) => { + const gridColumnExtraContext = this.getGridColumnExtraContext(column); + + return ({ + dataField: column.dataField, + caption: column.caption, + dataType: column.dataType, + visible: column.visible !== false, + sortOrder: column.sortOrder, + sortIndex: column.sortIndex, + fixed: column.fixed, + fixedPosition: column.fixedPosition, + width: column.width, + visibleIndex: column.visibleIndex, + ...gridColumnExtraContext, + }); + }); + } + + protected getGridExtraContext(): GridContext { + return {}; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected getGridColumnExtraContext(column: Column): GridContext { + return {}; + } + public init(): void { this.createAction('onAIAssistantRequestCreating'); } @@ -41,7 +121,6 @@ export class AIAssistantIntegrationController extends Controller { public sendRequest( text: string, responseSchema: JsonSchema, - extraContext: GridExtraContextOption | null, callbacks?: AIAssistantRequestCallbacks, ): void { if (this.isRequestAwaitingCompletion()) { @@ -52,18 +131,13 @@ export class AIAssistantIntegrationController extends Controller { if (aiIntegration === null) { errors.log('E1068'); + callbacks?.onError?.(errors.Error('E1068')); return; } - const context = this.buildContext(extraContext); - const args: { - context: Record; - responseSchema: JsonSchema; - cancel: boolean; - additionalInfo: Record; - } = { - context: context as unknown as Record, + const args = { responseSchema, + context: this.buildContext(), cancel: false, additionalInfo: {}, }; @@ -71,6 +145,7 @@ export class AIAssistantIntegrationController extends Controller { this.executeAction('onAIAssistantRequestCreating', args); if (args.cancel) { + callbacks?.onAbort?.(); return; } @@ -103,131 +178,4 @@ export class AIAssistantIntegrationController extends Controller { super.dispose(); this.abortRequest(); } - - private getAICommandCallbacks( - callbacks?: RequestCallbacks, - ): RequestCallbacks { - return { - onComplete: (finalResponse: ExecuteGridAssistantCommandResult): void => { - if (!this.isRequestAwaitingCompletion()) { - return; - } - this.processCommandCompletion(); - callbacks?.onComplete?.(finalResponse); - }, - onError: (error: Error): void => { - this.processCommandCompletion(); - callbacks?.onError?.(error); - }, - }; - } - - private processCommandCompletion(): void { - this.abort = undefined; - } - - public buildContext(extraContext: GridExtraContextOption | null): GridContext { - const dataController = this.getController('data'); - const selectedRowKeys = (this.option('selectedRowKeys') ?? []) as (string | number)[]; - const searchText = this.option('searchPanel.text') ?? ''; - const gridExtraContext = this.getGridExtraContext(extraContext?.grid); - - return { - columns: this.buildColumnsContext(extraContext?.column), - filtering: { - filterValue: this.option('filterValue'), - }, - paging: { - pageIndex: dataController.pageIndex(), - pageSize: dataController.pageSize(), - totalCount: dataController.totalCount(), - }, - search: { - searchText, - }, - selection: { - selectedRowKeys, - }, - ...gridExtraContext, - }; - } - - private buildColumnsContext( - extraContext?: GridExtraContextOption['column'], - ): GridColumnContext[] { - const columnsController = this.getController('columns'); - const allColumns: Column[] = columnsController.getColumns(); - - return allColumns - .filter((column) => !column.command) - .map((column): GridColumnContext => { - const gridColumnExtraContext = this.getGridColumnExtraContext(column, extraContext); - - return ({ - dataField: column.dataField, - caption: column.caption, - dataType: column.dataType, - visible: column.visible !== false, - sortOrder: column.sortOrder, - sortIndex: column.sortIndex, - filterValue: column.filterValue, - fixed: column.fixed, - fixedPosition: column.fixedPosition, - width: column.width, - visibleIndex: column.visibleIndex, - ...gridColumnExtraContext, - }); - }); - } - - private getGridExtraContext( - gridExtraContext?: GridExtraContextOption['grid'], - ): GridContextOptional | undefined { - if (!gridExtraContext?.length) { - return undefined; - } - - const context: GridContextOptional = {}; - - gridExtraContext.forEach((optionName) => { - switch (optionName) { - case 'summary': { - context.summary = { - totalItems: this.option('summary.totalItems'), - groupItems: this.option('summary.groupItems'), - skipEmptyValues: this.option('summary.skipEmptyValues'), - }; - break; - } - default: - break; - } - }); - - return context; - } - - private getGridColumnExtraContext( - column: Column, - gridColumnExtraContext?: GridExtraContextOption['column'], - ): GridColumnContextOptional | undefined { - if (!gridColumnExtraContext?.length) { - return undefined; - } - - const context: GridColumnContextOptional = {}; - - gridColumnExtraContext.forEach((optionName) => { - switch (optionName) { - case 'groupIndex': { - context.groupIndex = column.groupIndex; - break; - } - default: - break; - } - }); - - return context; - } } diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts index 098330eec2d4..92c8af953ddb 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts @@ -33,7 +33,7 @@ import { sortingCommand, } from './sorting'; -export const commandsCore = [ +export const coreCommands = [ columnsPinningCommand, columnsReorderCommand, columnsResizeCommand, @@ -53,26 +53,5 @@ export const commandsCore = [ selectByKeysCommand, clearSortingCommand, sortingCommand, + // TODO: try to remove "as GridCommand[]" ] as GridCommand[]; - -export default { - columnsPinningCommand, - columnsReorderCommand, - columnsResizeCommand, - columnsVisibilityCommand, - clearFilterCommand, - filterValueCommand, - focusRowByIndexCommand, - focusRowByKeyCommand, - pageIndexCommand, - pageSizeCommand, - pagingCommand, - searchingCommand, - clearSelectionCommand, - deselectAllCommand, - selectAllCommand, - selectByIndexesCommand, - selectByKeysCommand, - clearSortingCommand, - sortingCommand, -}; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts index 3e00a56180f5..bd8fad00a23a 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts @@ -1,8 +1,5 @@ -import type { DataType, SortOrder } from '@js/common'; import type { RequestCallbacks } from '@js/common/ai-integration'; -import type { FixedPosition } from '@js/common/grids'; import type { Message } from '@js/ui/chat'; -import type { SummaryGroupItem, SummaryTotalItem } from '@js/ui/data_grid'; import type { InternalGrid } from '@ts/grids/grid_core/m_types'; import type { z, ZodObject, ZodRawShape } from 'zod'; import type { JsonSchema7Type } from 'zod-to-json-schema'; @@ -66,61 +63,14 @@ export type CustomizeResponseText = ( // TODO: move to d.ts export type CustomizeResponseTitle = ( status: MessageStatus.Success | MessageStatus.Failure, - commandNames: GridCommand['name'][], + commandNames: string[], ) => string; export type AIAssistantRequestCallbacks = RequestCallbacks & { onAbort?: () => void; }; -export interface GridColumnContextOptional { - groupIndex?: number; -} - -export interface GridColumnContext extends GridColumnContextOptional { - dataField: string | undefined; - caption: string | undefined; - dataType: DataType | undefined; - visible: boolean; - sortOrder: SortOrder | undefined; - sortIndex: number | undefined; - filterValue: string | number | boolean | null | undefined; - fixed: boolean | undefined; - fixedPosition: FixedPosition | undefined; - width: number | string | undefined; - visibleIndex: number | undefined; -} - -export interface GridContextOptional { - summary?: { - totalItems: SummaryTotalItem[] | undefined; - groupItems: SummaryGroupItem[] | undefined; - skipEmptyValues: SummaryGroupItem['skipEmptyValues'] | undefined; - }; -} - -export interface GridContext extends GridContextOptional { - columns: GridColumnContext[]; - filtering: { - filterValue: string | unknown[] | Function | null | undefined; - }; - paging: { - pageIndex: number; - pageSize: number; - totalCount: number; - }; - search: { - searchText: string; - }; - selection: { - selectedRowKeys: (string | number)[]; - }; -} - -export interface GridExtraContextOption { - grid: (keyof GridContextOptional)[]; - column: (keyof GridColumnContextOptional)[]; -} +export type GridContext = Record; export type AIMessage = Message & { id: string; diff --git a/packages/devextreme/js/common/grids.d.ts b/packages/devextreme/js/common/grids.d.ts index 7ea51ed47f06..50bb52be6689 100644 --- a/packages/devextreme/js/common/grids.d.ts +++ b/packages/devextreme/js/common/grids.d.ts @@ -128,6 +128,11 @@ export type AIAssistantRequestCreatingInfo = { * @type object */ responseSchema: Record; + /** + * @docid + * @type object + */ + additionalInfo?: Record; }; /** diff --git a/packages/devextreme/testing/helpers/stubs/zodStub.js b/packages/devextreme/testing/helpers/stubs/zodStub.js new file mode 100644 index 000000000000..f63bbb45fef8 --- /dev/null +++ b/packages/devextreme/testing/helpers/stubs/zodStub.js @@ -0,0 +1,25 @@ +/** + * Minimal zod stub for QUnit / SystemJS tests. + * + * Wrapped in AMD define() so SystemJS CSP-production mode can load it. + */ + +define((require, exports) => { + const z = {}; + + [ + // top-level constructors + 'object', 'string', 'boolean', 'number', 'null', + 'enum', 'union', 'array', 'tuple', 'literal', 'record', 'lazy', + // chain modifiers + 'optional', 'nullable', 'strict', + 'int', 'nonnegative', + 'min', 'max', + // validation + 'safeParse', + ].forEach((name) => { z[name] = () => z; }); + + Object.defineProperty(exports, '__esModule', { value: true }); + exports.z = z; + exports.default = z; +}); diff --git a/packages/devextreme/testing/helpers/stubs/zodToJsonSchemaStub.js b/packages/devextreme/testing/helpers/stubs/zodToJsonSchemaStub.js new file mode 100644 index 000000000000..0c1d24334bc2 --- /dev/null +++ b/packages/devextreme/testing/helpers/stubs/zodToJsonSchemaStub.js @@ -0,0 +1,13 @@ +/** + * Minimal zod-to-json-schema stub for QUnit / SystemJS tests. + * + * Wrapped in AMD define() so SystemJS CSP-production mode can load it. + */ + +define((require, exports) => { + const zodToJsonSchema = () => ({ type: 'object' }); + + Object.defineProperty(exports, '__esModule', { value: true }); + exports.zodToJsonSchema = zodToJsonSchema; + exports.default = zodToJsonSchema; +}); diff --git a/packages/devextreme/testing/runner/lib/pages.ts b/packages/devextreme/testing/runner/lib/pages.ts index d3e25680066c..ebcca6045c88 100644 --- a/packages/devextreme/testing/runner/lib/pages.ts +++ b/packages/devextreme/testing/runner/lib/pages.ts @@ -183,9 +183,9 @@ export function createPagesRenderer({ json: '/packages/devextreme/node_modules/systemjs-plugin-json/json.js', 'plugin-babel': '/packages/devextreme/node_modules/systemjs-plugin-babel/plugin-babel.js', 'systemjs-babel-build': '/packages/devextreme/node_modules/systemjs-plugin-babel/systemjs-babel-browser.js', - // QUnit doesn't execute DataGrid AI assistant - zod: '@empty', - 'zod-to-json-schema': '@empty', + // Provide minimal stubs as those packages aren't used in QUnit tests + zod: '/packages/devextreme/testing/helpers/stubs/zodStub.js', + 'zod-to-json-schema': '/packages/devextreme/testing/helpers/stubs/zodToJsonSchemaStub.js', ...cspMap, }; diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index 760834ea59b2..2072c91d8338 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -4660,6 +4660,10 @@ declare module DevExpress.common.grids { * [descr:AIAssistantRequestCreatingInfo.responseSchema] */ responseSchema: Record; + /** + * [descr:AIAssistantRequestCreatingInfo.additionalInfo] + */ + additionalInfo?: Record; }; export type AIColumnMode = 'auto' | 'manual'; /** From 1fdcade5ee20436751a3a9debe277e36e887a08d Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 13 May 2026 20:39:35 +0400 Subject: [PATCH 3/4] One more attempt to fix qunit tests fix AIAssistantIntegrationController initialization fix type error fix tests after rebase fix after rebase --- .../ai_assistant/ai_assistant_controller.ts | 6 ++- .../__tests__/ai_assistant_controller.test.ts | 6 +-- .../ai_assistant/ai_assistant_controller.ts | 8 ++- .../grids/grid_core/ai_assistant/types.ts | 2 +- .../grids/grid_core/ai_chat/ai_chat.test.ts | 4 +- .../grids/grid_core/ai_chat/utils.test.ts | 6 +-- .../grids/grid_core/ai_chat/utils.ts | 4 +- .../testing/helpers/stubs/zodStub.js | 54 ++++++++++++------- .../helpers/stubs/zodToJsonSchemaStub.js | 17 +++--- 9 files changed, 66 insertions(+), 41 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_controller.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_controller.ts index 29c4e4c53b0b..9dce4c0d4fe4 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_controller.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_controller.ts @@ -1,12 +1,16 @@ import { AIAssistantController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_controller'; import type { GridCommand } from '@ts/grids/grid_core/ai_assistant/types'; -import type { DataGridAIAssistantIntegrationController } from './ai_assistant_integration_controller'; +import { DataGridAIAssistantIntegrationController } from './ai_assistant_integration_controller'; import { dataGridCommands } from './commands/index'; export class DataGridAIAssistantController extends AIAssistantController { protected aiAssistantIntegrationController?: DataGridAIAssistantIntegrationController; + protected getAiAssistantIntegrationController(): DataGridAIAssistantIntegrationController { + return new DataGridAIAssistantIntegrationController(this.component); + } + protected getGridCommandList(): GridCommand[] { const coreCommands = super.getGridCommandList(); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts index 579a191661a5..f6895075b8f2 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts @@ -193,12 +193,12 @@ describe('AIAssistantController', () => { { status: 'aborted', message: 'filter aborted' }, ]), abort: jest.fn(), + buildResponseSchema: jest.fn().mockReturnValue({ type: 'object' }), + isExecuting: jest.fn().mockReturnValue(false), }), ); - const controller = createController({ - 'aiAssistant.aiIntegration': mockAIIntegration, - }); + const controller = createController(); // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.sendRequestToAI({ diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts index 4c9b16e171a0..e35eb20bec68 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts @@ -35,7 +35,7 @@ export class AIAssistantController extends Controller { private processing = false; private getCustomizedResponseTitle( - status: MessageStatus.Success | MessageStatus.Failure, + status: MessageStatus, commandNames: string[], ): string { // TODO: remove type description, it should be got from d.ts @@ -232,10 +232,14 @@ export class AIAssistantController extends Controller { return [...coreCommands]; } + protected getAiAssistantIntegrationController(): AIAssistantIntegrationController { + return new AIAssistantIntegrationController(this.component); + } + public init(): void { this.gridCommands = new GridCommands(this.component, this.getGridCommandList()); this.messageStore = new ArrayStore({ key: 'id' }); - this.aiAssistantIntegrationController = new AIAssistantIntegrationController(this.component); + this.aiAssistantIntegrationController = this.getAiAssistantIntegrationController(); this.aiAssistantIntegrationController.init(); } diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts index bd8fad00a23a..4952accc565b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts @@ -62,7 +62,7 @@ export type CustomizeResponseText = ( // TODO: move to d.ts export type CustomizeResponseTitle = ( - status: MessageStatus.Success | MessageStatus.Failure, + status: MessageStatus, commandNames: string[], ) => string; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.test.ts index 20bacddc4c4a..caf36b8da0c0 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.test.ts @@ -548,7 +548,7 @@ describe('AIChat', () => { const chatConfig = getChatConfig(); const container = document.createElement('div'); - const commands: CommandResults = [ + const commands: CommandResult[] = [ { status: 'success', message: 'Sorted Name.' }, { status: 'aborted', message: 'Filter was aborted.' }, ]; @@ -572,7 +572,7 @@ describe('AIChat', () => { const chatConfig = getChatConfig(); const container = document.createElement('div'); - const commands: CommandResults = [ + const commands: CommandResult[] = [ { status: 'aborted', message: 'Filter was aborted.' }, ]; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/utils.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/utils.test.ts index 0ac8a2c2a6dd..b81c67b6489a 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/utils.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/utils.test.ts @@ -1,9 +1,7 @@ -import { - describe, expect, it, -} from '@jest/globals'; +import { describe, expect, it } from '@jest/globals'; import type { Message } from '@js/ui/chat'; +import { MessageStatus } from '@ts/grids/grid_core/ai_assistant/const'; -import { MessageStatus } from '../ai_assistant/const'; import { ABORTED_ITEM_EMOJI, CLASSES, ERROR_ITEM_EMOJI, SUCCESS_ITEM_EMOJI, } from './const'; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/utils.ts index 548b3fc4c663..6e30e0f9a71d 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/utils.ts @@ -1,7 +1,7 @@ import type { Message } from '@js/ui/chat'; +import { MessageStatus } from '@ts/grids/grid_core/ai_assistant/const'; +import type { CommandStatus } from '@ts/grids/grid_core/ai_assistant/types'; -import { MessageStatus } from '../ai_assistant/const'; -import type { CommandStatus } from '../ai_assistant/types'; import { ABORTED_ITEM_EMOJI, CLASSES, ERROR_ITEM_EMOJI, SUCCESS_ITEM_EMOJI, } from './const'; diff --git a/packages/devextreme/testing/helpers/stubs/zodStub.js b/packages/devextreme/testing/helpers/stubs/zodStub.js index f63bbb45fef8..78fd6c29ff18 100644 --- a/packages/devextreme/testing/helpers/stubs/zodStub.js +++ b/packages/devextreme/testing/helpers/stubs/zodStub.js @@ -1,25 +1,41 @@ /** * Minimal zod stub for QUnit / SystemJS tests. * - * Wrapped in AMD define() so SystemJS CSP-production mode can load it. + * Uses System.register format which works with both regular SystemJS + * and CSP-production mode (no eval required). */ -define((require, exports) => { - const z = {}; +System.register([], (exports) => ({ + execute() { + const z = { + // top-level constructors + object: () => z, + string: () => z, + boolean: () => z, + number: () => z, + null: () => z, + enum: () => z, + union: () => z, + array: () => z, + tuple: () => z, + literal: () => z, + record: () => z, + lazy: () => z, + // chain modifiers + optional: () => z, + nullable: () => z, + strict: () => z, + int: () => z, + // eslint-disable-next-line spellcheck/spell-checker + nonnegative: () => z, + min: () => z, + max: () => z, + // validation + safeParse: () => ({ success: true, data: {} }), + }; - [ - // top-level constructors - 'object', 'string', 'boolean', 'number', 'null', - 'enum', 'union', 'array', 'tuple', 'literal', 'record', 'lazy', - // chain modifiers - 'optional', 'nullable', 'strict', - 'int', 'nonnegative', - 'min', 'max', - // validation - 'safeParse', - ].forEach((name) => { z[name] = () => z; }); - - Object.defineProperty(exports, '__esModule', { value: true }); - exports.z = z; - exports.default = z; -}); + exports('z', z); + exports('default', z); + exports('__esModule', true); + }, +})); diff --git a/packages/devextreme/testing/helpers/stubs/zodToJsonSchemaStub.js b/packages/devextreme/testing/helpers/stubs/zodToJsonSchemaStub.js index 0c1d24334bc2..8893c585092d 100644 --- a/packages/devextreme/testing/helpers/stubs/zodToJsonSchemaStub.js +++ b/packages/devextreme/testing/helpers/stubs/zodToJsonSchemaStub.js @@ -1,13 +1,16 @@ /** * Minimal zod-to-json-schema stub for QUnit / SystemJS tests. * - * Wrapped in AMD define() so SystemJS CSP-production mode can load it. + * Uses System.register format which works with both regular SystemJS + * and CSP-production mode (no eval required). */ -define((require, exports) => { - const zodToJsonSchema = () => ({ type: 'object' }); +System.register([], (exports) => ({ + execute() { + const zodToJsonSchema = () => ({ type: 'object' }); - Object.defineProperty(exports, '__esModule', { value: true }); - exports.zodToJsonSchema = zodToJsonSchema; - exports.default = zodToJsonSchema; -}); + exports('zodToJsonSchema', zodToJsonSchema); + exports('default', zodToJsonSchema); + exports('__esModule', true); + }, +})); From d0482a893d62ce4225187e58a06ffef9468b566c Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Wed, 13 May 2026 22:51:56 +0400 Subject: [PATCH 4/4] Another attempt to fix qunit tests --- .../testing/helpers/stubs/zodStub.js | 76 ++++++++++--------- .../helpers/stubs/zodToJsonSchemaStub.js | 25 +++--- 2 files changed, 56 insertions(+), 45 deletions(-) diff --git a/packages/devextreme/testing/helpers/stubs/zodStub.js b/packages/devextreme/testing/helpers/stubs/zodStub.js index 78fd6c29ff18..cb64b2851956 100644 --- a/packages/devextreme/testing/helpers/stubs/zodStub.js +++ b/packages/devextreme/testing/helpers/stubs/zodStub.js @@ -1,41 +1,47 @@ /** * Minimal zod stub for QUnit / SystemJS tests. * - * Uses System.register format which works with both regular SystemJS - * and CSP-production mode (no eval required). + * Uses AMD define() when available (CSP mode) and falls back + * to global assignment for regular SystemJS (NoCsp mode). */ -System.register([], (exports) => ({ - execute() { - const z = { - // top-level constructors - object: () => z, - string: () => z, - boolean: () => z, - number: () => z, - null: () => z, - enum: () => z, - union: () => z, - array: () => z, - tuple: () => z, - literal: () => z, - record: () => z, - lazy: () => z, - // chain modifiers - optional: () => z, - nullable: () => z, - strict: () => z, - int: () => z, - // eslint-disable-next-line spellcheck/spell-checker - nonnegative: () => z, - min: () => z, - max: () => z, - // validation - safeParse: () => ({ success: true, data: {} }), - }; +(function() { + const z = { + // top-level constructors + object: function() { return z; }, + string: function() { return z; }, + boolean: function() { return z; }, + number: function() { return z; }, + null: function() { return z; }, + enum: function() { return z; }, + union: function() { return z; }, + array: function() { return z; }, + tuple: function() { return z; }, + literal: function() { return z; }, + record: function() { return z; }, + lazy: function() { return z; }, + // chain modifiers + optional: function() { return z; }, + nullable: function() { return z; }, + strict: function() { return z; }, + int: function() { return z; }, + // eslint-disable-next-line spellcheck/spell-checker + nonnegative: function() { return z; }, + min: function() { return z; }, + max: function() { return z; }, + // validation + safeParse: function() { return { success: true, data: {} }; }, + }; - exports('z', z); - exports('default', z); - exports('__esModule', true); - }, -})); + if(typeof define === 'function') { + define(function(require, exports) { + Object.defineProperty(exports, '__esModule', { value: true }); + exports.z = z; + exports.default = z; + }); + } else { + const root = typeof self !== 'undefined' ? self : typeof window !== 'undefined' ? window : globalThis; + root.z = z; + root.zod = z; + } +})(); diff --git a/packages/devextreme/testing/helpers/stubs/zodToJsonSchemaStub.js b/packages/devextreme/testing/helpers/stubs/zodToJsonSchemaStub.js index 8893c585092d..a7aa7e3e9d70 100644 --- a/packages/devextreme/testing/helpers/stubs/zodToJsonSchemaStub.js +++ b/packages/devextreme/testing/helpers/stubs/zodToJsonSchemaStub.js @@ -1,16 +1,21 @@ /** * Minimal zod-to-json-schema stub for QUnit / SystemJS tests. * - * Uses System.register format which works with both regular SystemJS - * and CSP-production mode (no eval required). + * Uses AMD define() when available (CSP mode) and falls back + * to global assignment for regular SystemJS (NoCsp mode). */ -System.register([], (exports) => ({ - execute() { - const zodToJsonSchema = () => ({ type: 'object' }); +(function() { + const zodToJsonSchema = function() { return { type: 'object' }; }; - exports('zodToJsonSchema', zodToJsonSchema); - exports('default', zodToJsonSchema); - exports('__esModule', true); - }, -})); + if(typeof define === 'function') { + define(function(require, exports) { + Object.defineProperty(exports, '__esModule', { value: true }); + exports.zodToJsonSchema = zodToJsonSchema; + exports.default = zodToJsonSchema; + }); + } else { + const root = typeof self !== 'undefined' ? self : typeof window !== 'undefined' ? window : globalThis; + root.zodToJsonSchema = zodToJsonSchema; + } +})();