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..85854cf42aca --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_controller.ts @@ -0,0 +1,22 @@ +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/index'; + +export class DataGridAIAssistantController extends AIAssistantController { + protected getGridCommandList(): GridCommand[] { + const coreCommands = super.getGridCommandList(); + + return [ + ...coreCommands, + ...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..6096c572384e --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/index.ts @@ -0,0 +1,18 @@ +import type { GridCommand } from '@ts/grids/grid_core/ai_assistant/types'; + +import { clearGroupingCommand, groupingCommand } from './grouping'; +import { clearSummaryCommand, summaryCommand } from './summary'; + +export const dataGridCommands = [ + groupingCommand, + clearGroupingCommand, + summaryCommand, + clearSummaryCommand, +] as GridCommand[]; + +export default { + 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 e5fafe220781..39897ff6b197 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 = {}, @@ -73,6 +67,28 @@ describe('AIAssistantController', () => { 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, + _extraContext: unknown, + callbacks?: AIAssistantRequestCallbacks, + ) => { + sendRequestCallbacks = callbacks ?? {}; + }), + abortRequest: jest.fn(() => { + sendRequestCallbacks.onAbort?.(); + }), + isRequestAwaitingCompletion: jest.fn().mockReturnValue(false), }), ); }); @@ -90,9 +106,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 +132,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 +158,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({ @@ -167,9 +186,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' }, @@ -195,9 +212,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' }, @@ -227,9 +242,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' }, @@ -244,9 +257,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' }, @@ -260,9 +271,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' }, @@ -276,9 +285,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({ @@ -296,13 +303,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' }, @@ -324,13 +333,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' }, @@ -352,13 +363,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' }, @@ -380,15 +393,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' }, @@ -414,9 +429,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' }, @@ -437,9 +450,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', @@ -471,9 +482,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', @@ -497,9 +506,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', @@ -514,18 +521,20 @@ 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), + null, 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', @@ -555,9 +564,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 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 448775bc8706..17291db85bd6 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,8 +1,12 @@ -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'; @@ -10,11 +14,16 @@ import { fromPromise } from '@ts/core/utils/m_deferred'; import { hasCommandErrors } from '../ai_chat/utils'; import { Controller } from '../m_modules'; import { AIAssistantIntegrationController } from './ai_assistant_integration_controller'; +import { coreCommands } from './commands/index'; 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 { isAIMessage } from './utils'; @@ -27,9 +36,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 { @@ -42,8 +80,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')); } @@ -53,7 +96,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')); @@ -84,13 +128,17 @@ 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 = hasCommandErrors(commands) ? MessageStatus.Failure : MessageStatus.Success; this.updateAIMessage(messageId, { - headerText: this.getCustomizedResponseTitle(), + headerText: this.getCustomizedResponseTitle(messageStatus, commandNames), commands, status: messageStatus, // WA to trigger status update, remove when dxChat supports @@ -130,43 +178,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 [...coreCommands]; + } + + 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..abc75ad5fec9 --- /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 coreCommands = [ + 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 f6729534c590..c66502880d36 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 @@ -18,7 +18,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(), @@ -342,7 +342,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.' }, ]; @@ -366,7 +366,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.' }, ]; 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 c22896a55241..d2b39ef525f5 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 @@ -28,9 +28,7 @@ import { REGENERATE_ICON, SUCCESS_ITEM_EMOJI, } from './const'; -import type { - AIChatOptions, CommandResult, CommandResults, -} from './types'; +import type { AIChatOptions, CommandResult } from './types'; import { findMessageById, getMessageIconName, @@ -238,7 +236,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 6f754d44c9f4..fe44e4f1dfce 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; 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 d50fb6eb61cd..4f87ca425b63 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 @@ -2,7 +2,7 @@ import type { Message } from '@js/ui/chat'; import { MessageStatus } from '../ai_assistant/const'; import { CLASSES } from './const'; -import type { CommandResults } from './types'; +import type { CommandResult } from './types'; export const getMessageStateClass = (status: MessageStatus): string => { switch (status) { @@ -17,7 +17,7 @@ export const getMessageStateClass = (status: MessageStatus): string => { }; export const hasCommandErrors = ( - commands: CommandResults | undefined, + commands: CommandResult[] | undefined, ): boolean => !!commands?.some(({ status }) => status === MessageStatus.Failure); export const getMessageIconName = (message: Message): string => { diff --git a/packages/devextreme/testing/helpers/grid/zodStub.js b/packages/devextreme/testing/helpers/grid/zodStub.js new file mode 100644 index 000000000000..186ab6c4d1ec --- /dev/null +++ b/packages/devextreme/testing/helpers/grid/zodStub.js @@ -0,0 +1,60 @@ +/** + * Minimal zod stub for QUnit / SystemJS tests. + * + * The AI-assistant command modules call z.object(), z.string(), etc. + * at evaluation time. Because QUnit tests never exercise the AI assistant, + * we only need the runtime not to crash — the returned objects are never + * used for real validation. + */ +const chainable = function chainable() { + const self = { + optional: function() { + return self; + }, + nullable: function() { + return self; + }, + strict: function() { + return self; + }, + int: function() { + return self; + }, + // eslint-disable-next-line spellcheck/spell-checker + nonnegative: function() { + return self; + }, + safeParse: function() { + return { success: true }; + }, + }; + return self; +}; + +const z = { + object: function() { + return chainable(); + }, + string: function() { + return chainable(); + }, + boolean: function() { + return chainable(); + }, + number: function() { + return chainable(); + }, + enum: function() { + return chainable(); + }, + union: function() { + return chainable(); + }, + array: function() { + return chainable(); + }, +}; + +exports.z = z; +exports.default = z; + diff --git a/packages/devextreme/testing/helpers/grid/zodToJsonSchemaStub.js b/packages/devextreme/testing/helpers/grid/zodToJsonSchemaStub.js new file mode 100644 index 000000000000..9c35b02d70ba --- /dev/null +++ b/packages/devextreme/testing/helpers/grid/zodToJsonSchemaStub.js @@ -0,0 +1,14 @@ +/** + * Minimal zod-to-json-schema stub for QUnit / SystemJS tests. + * + * grid_commands.ts calls zodToJsonSchema() to build the response schema + * sent to the LLM. QUnit never exercises this path, so a no-op is fine. + */ + +function zodToJsonSchema() { + return { type: 'object' }; +} + +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..395f1c72058f 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/grid/zodStub.js', + 'zod-to-json-schema': '/packages/devextreme/testing/helpers/grid/zodToJsonSchemaStub.js', ...cspMap, };