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..9dce4c0d4fe4 --- /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 } from '@ts/grids/grid_core/ai_assistant/types'; + +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(); + + 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 new file mode 100644 index 000000000000..b33c7d3ab445 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/index.ts @@ -0,0 +1,12 @@ +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, + // TODO: try to remove "as GridCommand[]" +] as GridCommand[]; 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..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 @@ -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({ @@ -176,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({ @@ -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 3f5ba0087f54..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,6 +22,9 @@ import { createDataGrid, } from '../../__tests__/__mock__/helpers/utils'; import { AIAssistantIntegrationController } from '../ai_assistant_integration_controller'; +import type { GridContext, JsonSchema } from '../types'; + +const STUB_SCHEMA: JsonSchema = { type: 'object' }; interface SendRequestResult { promise: Promise; @@ -98,7 +101,7 @@ describe('AIAssistantIntegrationController', () => { it('should log E1068', async () => { const controller = await createController({}); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(errors.log).toHaveBeenCalledWith('E1068'); }); @@ -111,7 +114,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(aiIntegration.executeGridAssistant) .toHaveBeenCalledTimes(1); @@ -126,7 +129,7 @@ describe('AIAssistantIntegrationController', () => { aiIntegration, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(aiIntegration.executeGridAssistant) .toHaveBeenCalledTimes(1); @@ -143,7 +146,7 @@ describe('AIAssistantIntegrationController', () => { aiIntegration: gridAI, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(assistantAI.executeGridAssistant) .toHaveBeenCalledTimes(1); @@ -155,21 +158,38 @@ 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'); + 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: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + + const customSchema: JsonSchema = { type: 'object', properties: { action: { type: 'string' } } }; + + const controller = await createController({ + aiAssistant: { enabled: true, aiIntegration }, + }); + + controller.sendRequest('Sort by name', customSchema); + + expect(capturedParams.responseSchema).toEqual(customSchema); + }); + it('should abort previous request when sending new one', async () => { const abortSpy = jest.fn(); const aiIntegration = createMockAIIntegration(); @@ -182,12 +202,32 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(abortSpy).not.toHaveBeenCalled(); - controller.sendRequest('Sort by id'); + controller.sendRequest('Sort by id', STUB_SCHEMA); 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); + capturedCallbacks.onError?.(new Error('Network error')); + + expect(controller.isRequestAwaitingCompletion()).toBe(false); + + controller.sendRequest('Sort by id', STUB_SCHEMA); + expect(controller.isRequestAwaitingCompletion()).toBe(true); + expect(aiIntegration.executeGridAssistant).toHaveBeenCalledTimes(2); + }); }); describe('abortRequest', () => { @@ -203,7 +243,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(controller.isRequestAwaitingCompletion()).toBe(true); controller.abortRequest(); @@ -221,7 +261,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', { + controller.sendRequest('Sort by name', STUB_SCHEMA, { onComplete: jest.fn(), onError: jest.fn(), onAbort, @@ -240,13 +280,13 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', { + controller.sendRequest('Sort by name', STUB_SCHEMA, { onComplete: jest.fn(), onError: jest.fn(), onAbort, }); - controller.sendRequest('Sort by id'); + controller.sendRequest('Sort by id', STUB_SCHEMA); expect(onAbort).toHaveBeenCalledTimes(1); }); @@ -258,7 +298,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(() => { controller.abortRequest(); @@ -276,7 +316,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', { + controller.sendRequest('Sort by name', STUB_SCHEMA, { onComplete: jest.fn(), onError: jest.fn(), onAbort, @@ -300,7 +340,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name', { + controller.sendRequest('Sort by name', STUB_SCHEMA, { onComplete: jest.fn(), onError: jest.fn(), onAbort, @@ -312,6 +352,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, { + 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 +393,7 @@ describe('AIAssistantIntegrationController', () => { aiAssistant: { enabled: true, aiIntegration }, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(controller.isRequestAwaitingCompletion()).toBe(true); expect(abortSpy).not.toHaveBeenCalled(); controller.dispose(); @@ -351,7 +419,7 @@ describe('AIAssistantIntegrationController', () => { onAIAssistantRequestCreating, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(callOrder).toEqual([ 'onAIAssistantRequestCreating', @@ -368,7 +436,7 @@ describe('AIAssistantIntegrationController', () => { onAIAssistantRequestCreating, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(onAIAssistantRequestCreating).toHaveBeenCalledWith( expect.objectContaining({ @@ -395,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'); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(capturedProviderParams.prompt).toEqual( expect.objectContaining({ @@ -431,52 +499,72 @@ describe('AIAssistantIntegrationController', () => { }, }); - controller.sendRequest('Sort by name'); + 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'); + 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'); + 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'); }); @@ -488,11 +576,327 @@ describe('AIAssistantIntegrationController', () => { onAIAssistantRequestCreating, }); - controller.sendRequest('Sort by name'); + controller.sendRequest('Sort by name', STUB_SCHEMA); expect(onAIAssistantRequestCreating).not.toHaveBeenCalled(); expect(errors.log).toHaveBeenCalledWith('E1068'); }); }); }); + + 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, + columns: [ + { + dataField: 'id', caption: 'ID', dataType: 'number', visible: true, + }, + { + dataField: 'name', caption: 'Name', dataType: 'string', visible: false, + }, + ], + }); + + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const columns = context.columns as GridContext[]; + + 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, + columns: [ + { + dataField: 'id', + caption: 'ID', + dataType: 'number', + visible: true, + sortOrder: 'asc', + sortIndex: 0, + fixed: true, + fixedPosition: 'left', + width: 100, + }, + ], + }); + + 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', + caption: 'ID', + dataType: 'number', + visible: true, + sortOrder: 'asc', + sortIndex: 0, + fixed: true, + fixedPosition: 'left', + width: 100, + })); + expect('visibleIndex' 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, + columns: [ + { + dataField: 'id', + caption: 'ID', + dataType: 'number', + allowSorting: true, + }, + ], + }); + + 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', + 'fixed', 'fixedPosition', 'width', 'visibleIndex', + ]; + + expect(columnKeys.sort()).toEqual(expectedKeys.sort()); + }); + + it('should exclude command columns', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + + const controller = await createController({ + aiIntegration, + selection: { mode: 'multiple' }, + columns: [ + { dataField: 'id', caption: 'ID', dataType: 'number' }, + { dataField: 'name', caption: 'Name', dataType: 'string' }, + ], + }); + + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const columns = context.columns as GridContext[]; + + 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, + dataSource: [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + { id: 3, name: 'C' }, + ], + paging: { pageSize: 2, pageIndex: 0 }, + }); + + controller.sendRequest('test', STUB_SCHEMA); + + 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, + }); + + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const search = context.search as GridContext; + + 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, + searchPanel: { visible: true, text: 'test search' }, + }); + + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const search = context.search as GridContext; + + 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, + selection: { mode: 'multiple' }, + }); + + controller.sendRequest('test', STUB_SCHEMA); + + 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, + dataSource: [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ], + selection: { mode: 'multiple' }, + selectedRowKeys: [1, 2], + }); + + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const selection = context.selection as GridContext; + + 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, + }); + + controller.sendRequest('test', STUB_SCHEMA); + + 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, + filterValue: filterExpression, + }); + + controller.sendRequest('test', STUB_SCHEMA); + + 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' }, + { id: 2, name: 'A' }, + ], + columns: [ + { dataField: 'id', caption: 'ID', dataType: 'number' }, + { dataField: 'name', caption: 'Name', dataType: 'string' }, + ], + aiIntegration, + } as unknown as Properties); + + const controller = new AIAssistantIntegrationController(instance); + controller.init(); + + 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(); + + 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'); + }); + }); }); 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..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 @@ -1,19 +1,27 @@ -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 { 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, } from './types'; import { getMessageStatus, isAIMessage } from './utils'; @@ -22,13 +30,41 @@ export class AIAssistantController extends Controller { private messageStore?: ArrayStore; - private aiAssistantIntegrationController?: AIAssistantIntegrationController; + protected aiAssistantIntegrationController?: AIAssistantIntegrationController; private processing = false; - // TODO: need to implement method for getting customized response title - private getCustomizedResponseTitle(): string { - return ''; + private getCustomizedResponseTitle( + status: MessageStatus, + commandNames: string[], + ): string { + // TODO: remove type description, it should be got from d.ts + const customizeResponseTitle = this.option('aiAssistant.customizeResponseTitle') as CustomizeResponseTitle | undefined; + + 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[]): string[] { + const commandNames = actions.map(({ name }) => name); + const uniqueCommandNameSet = new Set(commandNames); + + return Array.from(uniqueCommandNameSet); } private updateAIMessage(messageId: string, data: Partial): void { @@ -41,8 +77,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 +93,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 +125,15 @@ export class AIAssistantController extends Controller { return aiMessage; } - private completeAIMessage(messageId: string, commands: CommandResults): void { + private completeAIMessage( + messageId: string, + commands: CommandResult[], + commandNames: string[], + ): 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 @@ -123,51 +169,77 @@ export class AIAssistantController extends Controller { }); } - private sendRequestToAICore(aiMessage: AIMessage): Promise { + private withProcessing(promise: Promise): Promise { 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')); + return promise.finally(() => { + this.setProcessing(false); + }); + } - this.failAIMessage(aiMessage.id, error); - this.setProcessing(false); - reject(error); + private sendRequestToAICore(aiMessage: AIMessage): Promise { + return this.withProcessing(new Promise((resolve, reject) => { + const responseSchema = this.gridCommands?.buildResponseSchema(); + + if (!responseSchema) { + // TODO: Change error message + const error = new Error('Grid commands not initialized'); + + this.failAIMessage(aiMessage.id, error); + reject(error); + return; + } + + this.aiAssistantIntegrationController?.sendRequest( + aiMessage.prompt, + responseSchema, + { + onComplete: (response: ExecuteGridAssistantCommandResult): void => { + fromPromise(this.processResponse(response)) + .done((commands: CommandResult[]) => { + const commandNames = this.getCommandNames(response.actions); + + this.completeAIMessage(aiMessage.id, commands, commandNames); + resolve(); + }) + .fail((errorMessage) => { + // TODO: Change error message + const error = errorMessage instanceof Error + ? errorMessage + : new Error(String(errorMessage)); + + this.failAIMessage(aiMessage.id, error); + reject(error); + }); + }, + onError: (error: Error): void => { + // TODO: Change error message + this.failAIMessage(aiMessage.id, error); + reject(error); + }, + onAbort: (): void => { + const error = new Error(messageLocalization.format('dxDataGrid-aiAssistantAbortMessage')); + + this.failAIMessage(aiMessage.id, error); + reject(error); + }, }, - }); - }); + ); + })); + } + + protected getGridCommandList(): GridCommand[] { + return [...coreCommands]; + } + + protected getAiAssistantIntegrationController(): AIAssistantIntegrationController { + return new AIAssistantIntegrationController(this.component); } public init(): void { - // TODO: initialize default commands list when they are ready - this.gridCommands = new GridCommands(this.component, []); - this.messageStore = new ArrayStore({ - key: 'id', - }); - this.aiAssistantIntegrationController = new AIAssistantIntegrationController(this.component); + this.gridCommands = new GridCommands(this.component, this.getGridCommandList()); + this.messageStore = new ArrayStore({ key: 'id' }); + this.aiAssistantIntegrationController = this.getAiAssistantIntegrationController(); 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 d719f8f8ff91..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 @@ -4,13 +4,102 @@ 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, + GridContext, + 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'); } @@ -26,12 +115,12 @@ export class AIAssistantIntegrationController extends Controller { return gridAIIntegration; } - errors.log('E1068'); return null; } public sendRequest( text: string, + responseSchema: JsonSchema, callbacks?: AIAssistantRequestCallbacks, ): void { if (this.isRequestAwaitingCompletion()) { @@ -39,22 +128,24 @@ export class AIAssistantIntegrationController extends Controller { } const aiIntegration = this.getAIIntegration(); - if (!aiIntegration) { + + if (aiIntegration === null) { + errors.log('E1068'); + callbacks?.onError?.(errors.Error('E1068')); return; } - const context = this.buildContext(); - const responseSchema = AIAssistantIntegrationController.buildResponseSchema(); - const args = { - context, responseSchema, + context: this.buildContext(), cancel: false, - additionalInfo: {} as Record, + additionalInfo: {}, }; + this.executeAction('onAIAssistantRequestCreating', args); if (args.cancel) { + callbacks?.onAbort?.(); return; } @@ -87,36 +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; - } - - // TODO: implement buildContext with grid commands - private buildContext(): Record { - return {}; - } - - // TODO: implement buildResponseSchema with grid commands - private static buildResponseSchema(): Record { - return {}; - } } 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..92c8af953ddb --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts @@ -0,0 +1,57 @@ +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, + // TODO: try to remove "as GridCommand[]" +] as GridCommand[]; 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..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 @@ -18,8 +18,6 @@ export interface CommandResult { message: string; } -export type CommandResults = CommandResult[]; - export interface CommandCallbacks { success: (message?: string) => CommandResult; failure: (message?: string) => CommandResult; @@ -56,20 +54,29 @@ 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, + commandNames: string[], +) => string; + export type AIAssistantRequestCallbacks = RequestCallbacks & { onAbort?: () => void; }; +export type GridContext = Record; + 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..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 @@ -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.' }, @@ -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/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; 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/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..cb64b2851956 --- /dev/null +++ b/packages/devextreme/testing/helpers/stubs/zodStub.js @@ -0,0 +1,47 @@ +/** + * Minimal zod stub for QUnit / SystemJS tests. + * + * Uses AMD define() when available (CSP mode) and falls back + * to global assignment for regular SystemJS (NoCsp mode). + */ + +(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: {} }; }, + }; + + 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 new file mode 100644 index 000000000000..a7aa7e3e9d70 --- /dev/null +++ b/packages/devextreme/testing/helpers/stubs/zodToJsonSchemaStub.js @@ -0,0 +1,21 @@ +/** + * Minimal zod-to-json-schema stub for QUnit / SystemJS tests. + * + * Uses AMD define() when available (CSP mode) and falls back + * to global assignment for regular SystemJS (NoCsp mode). + */ + +(function() { + const zodToJsonSchema = function() { return { type: 'object' }; }; + + 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; + } +})(); 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'; /**