From 1a8e838a482599a54413cb742e1bef6f475cf24e Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Thu, 14 May 2026 22:33:43 +0400 Subject: [PATCH 1/2] DataGrid - AI Assistant: Support Store Push API --- .../__tests__/ai_assistant_controller.test.ts | 17 +-- .../__tests__/ai_assistant_view.test.ts | 122 +++++++++++++++++- .../ai_assistant/__tests__/utils.test.ts | 38 ++++++ .../ai_assistant/ai_assistant_controller.ts | 7 +- .../ai_assistant/ai_assistant_view.ts | 29 ++++- .../grids/grid_core/ai_assistant/utils.ts | 5 + .../grids/grid_core/ai_chat/ai_chat.test.ts | 29 +++++ .../grids/grid_core/ai_chat/ai_chat.ts | 4 + 8 files changed, 231 insertions(+), 20 deletions(-) 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 f6895075b8f2..16ca0e6940a2 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 @@ -51,10 +51,9 @@ const createController = ( return controller; }; -const getStore = (controller: AIAssistantController): ArrayStore => { - const dataSource = controller.getMessageDataSource() as { store: ArrayStore }; - return dataSource.store; -}; +const getStore = ( + controller: AIAssistantController, +): ArrayStore => controller.getMessageStore(); describe('AIAssistantController', () => { beforeEach(() => { @@ -91,14 +90,12 @@ describe('AIAssistantController', () => { ); }); - describe('getMessageDataSource', () => { - it('should return dataSource with store', () => { + describe('getMessageStore', () => { + it('should return message store', () => { const controller = createController(); - const dataSource = controller.getMessageDataSource() as { - store: ArrayStore; - }; + const store = controller.getMessageStore(); - expect(dataSource.store).toBeDefined(); + expect(store).toBeDefined(); }); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view.test.ts index 8add806a5a83..a0ea460a3501 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view.test.ts @@ -35,9 +35,9 @@ const createComponentMock = jest.fn(( options: any, ): any => new Widget(el, options)); -const mockMessageDataSource = { store: new ArrayStore({ key: 'id' }), reshapeOnPush: true }; +const mockMessageStore = new ArrayStore({ key: 'id' }); const mockAIAssistantController = { - getMessageDataSource: jest.fn().mockReturnValue(mockMessageDataSource), + getMessageStore: jest.fn().mockReturnValue(mockMessageStore), sendRequestToAI: jest.fn(), abortRequest: jest.fn(), }; @@ -110,6 +110,7 @@ const beforeTest = (): void => { }; const afterTest = (): void => { + mockMessageStore.off('push'); document.body.innerHTML = ''; fx.off = false; jest.useRealTimers(); @@ -159,9 +160,9 @@ describe('AIAssistantView', () => { const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions; - expect(mockAIAssistantController.getMessageDataSource).toHaveBeenCalledTimes(1); + expect(mockAIAssistantController.getMessageStore).toHaveBeenCalledTimes(1); expect(aiChatConfig.chatOptions).toEqual(expect.objectContaining({ - dataSource: mockMessageDataSource, + dataSource: expect.objectContaining({ store: mockMessageStore }), reloadOnChange: true, onMessageEntered: expect.any(Function), })); @@ -460,6 +461,119 @@ describe('AIAssistantView', () => { expect(aiChatInstance.setDisabled).toHaveBeenLastCalledWith(false); }); }); + + describe('handleMessageStorePush', () => { + const USER_ID = 'user'; + + const getPushHandler = (): (changes: any[]) => void => { + const onSpy = jest.spyOn(mockMessageStore, 'on'); + createAIAssistantView(); + + const aiChatInstance = (AIChat as jest.Mock) + .mock.results[0].value as { getUserId: jest.Mock }; + aiChatInstance.getUserId.mockReturnValue(USER_ID); + + const pushCall = (onSpy.mock.calls as any[][]).find((call) => call[0] === 'push'); + onSpy.mockRestore(); + + if (!pushCall?.[1]) { + throw new Error('Push handler not found'); + } + + return pushCall[1] as (changes: any[]) => void; + }; + + it('should subscribe to store push event during render', () => { + const onSpy = jest.spyOn(mockMessageStore, 'on'); + createAIAssistantView(); + + expect(onSpy).toHaveBeenCalledWith('push', expect.any(Function)); + onSpy.mockRestore(); + }); + + it('should unsubscribe from previous push handler before subscribing', () => { + const offSpy = jest.spyOn(mockMessageStore, 'off'); + const onSpy = jest.spyOn(mockMessageStore, 'on'); + createAIAssistantView(); + + const onPushCall = (onSpy.mock.calls as any[][]).find((call) => call[0] === 'push') as any[]; + const offPushCall = (offSpy.mock.calls as any[][]).find((call) => call[0] === 'push') as any[]; + + expect(offPushCall).toBeDefined(); + expect(onPushCall).toBeDefined(); + expect(offPushCall[1]).toBe(onPushCall[1]); + + offSpy.mockRestore(); + onSpy.mockRestore(); + }); + + it('should call sendRequestToAI when user message is inserted via store push', () => { + mockAIAssistantController.sendRequestToAI.mockReturnValue(Promise.resolve()); + const pushHandler = getPushHandler(); + + const userMessage = { + id: 'user-msg-1', + author: { id: USER_ID, name: 'User' }, + text: 'Sort by name', + }; + + pushHandler([{ type: 'insert', data: userMessage }]); + + expect(mockAIAssistantController.sendRequestToAI).toHaveBeenCalledTimes(1); + expect(mockAIAssistantController.sendRequestToAI).toHaveBeenCalledWith(userMessage); + }); + + it('should not call sendRequestToAI when AI message is inserted via store push', () => { + const pushHandler = getPushHandler(); + + const aiMessage = { + id: 'assistant-msg-1', + author: { id: 'assistant' }, + text: 'Done', + prompt: 'Sort by name', + status: 'success', + headerText: 'Sort', + }; + + pushHandler([{ type: 'insert', data: aiMessage }]); + + expect(mockAIAssistantController.sendRequestToAI).not.toHaveBeenCalled(); + }); + + it('should not call sendRequestToAI when message from another user is inserted via store push', () => { + const pushHandler = getPushHandler(); + + const otherUserMessage = { + id: 'other-msg-1', + author: { id: 'other-user', name: 'Other' }, + text: 'Hello', + }; + + pushHandler([{ type: 'insert', data: otherUserMessage }]); + + expect(mockAIAssistantController.sendRequestToAI).not.toHaveBeenCalled(); + }); + + it('should not call sendRequestToAI when message is updated via store push', () => { + const pushHandler = getPushHandler(); + + pushHandler([{ + type: 'update', + key: 'msg-1', + data: { text: 'updated' }, + }]); + + expect(mockAIAssistantController.sendRequestToAI).not.toHaveBeenCalled(); + }); + + it('should not call sendRequestToAI when message is removed via store push', () => { + const pushHandler = getPushHandler(); + + pushHandler([{ type: 'delete', key: 'msg-1' }]); + + expect(mockAIAssistantController.sendRequestToAI).not.toHaveBeenCalled(); + }); + }); }); describe('optionChanged', () => { diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/utils.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/utils.test.ts index 7b59661006b3..28b86bd63863 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/utils.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/utils.test.ts @@ -13,6 +13,7 @@ import { isEnabledOption, isPopupOptions, isTitleOption, + isUserMessage, } from '../utils'; describe('isAIMessage', () => { @@ -43,6 +44,43 @@ describe('isAIMessage', () => { }); }); +describe('isUserMessage', () => { + it('should return true when message author id matches userId', () => { + const message = { + author: { id: 'user-1', name: 'User' }, + text: 'request', + } as Message; + + expect(isUserMessage(message, 'user-1')).toBe(true); + }); + + it('should return false when message author id does not match userId', () => { + const message = { + author: { id: 'user-1', name: 'User' }, + text: 'request', + } as Message; + + expect(isUserMessage(message, 'user-2')).toBe(false); + }); + + it('should return false for AI message', () => { + const message = { + author: { id: AI_ASSISTANT_AUTHOR_ID }, + text: 'response', + } as Message; + + expect(isUserMessage(message, 'user-1')).toBe(false); + }); + + it('should return false for message without author', () => { + const message = { + text: 'request', + } as Message; + + expect(isUserMessage(message, 'user-1')).toBe(false); + }); +}); + describe('isEnabledOption', () => { it('should return true for enabled option names', () => { expect(isEnabledOption('aiAssistant.enabled', true)).toBe(true); 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 e35eb20bec68..b0659b70718b 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 @@ -7,7 +7,6 @@ import { ArrayStore } from '@js/common/data'; import Guid from '@js/core/guid'; 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'; @@ -243,10 +242,8 @@ export class AIAssistantController extends Controller { this.aiAssistantIntegrationController.init(); } - public getMessageDataSource(): DataSourceLike { - return { - store: this.messageStore, - }; + public getMessageStore(): ArrayStore { + return this.messageStore ?? new ArrayStore({ key: 'id' }); } public sendRequestToAI(message: Message | AIMessage): Promise { diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view.ts index d569c81dd947..dec2d6f42449 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view.ts @@ -1,4 +1,5 @@ import type { PositionConfig } from '@js/common/core/animation'; +import type { ArrayStore } from '@js/common/data'; import type { Callback } from '@js/core/utils/callbacks'; import { getHeight } from '@js/core/utils/size'; import type { Message, Properties as ChatProperties } from '@js/ui/chat'; @@ -10,10 +11,12 @@ import { isEnabledOption, isPopupOptions, isTitleOption, + isUserMessage, } from '@ts/grids/grid_core/ai_assistant/utils'; import type { ColumnHeadersView } from '@ts/grids/grid_core/column_headers/m_column_headers'; import type { OptionChanged } from '@ts/grids/grid_core/m_types'; import type { RowsView } from '@ts/grids/grid_core/views/m_rows_view'; +import type { DataChange } from '@ts/ui/collection/collection_widget.base'; import { AIChat } from '../ai_chat/ai_chat'; import type { AIChatOptions } from '../ai_chat/types'; @@ -30,12 +33,15 @@ export class AIAssistantView extends View { private rowsView!: RowsView; + private handleMessageStorePushContext!: (changes: DataChange[]) => void; + public visibilityChanged?: Callback; public init(): void { this.columnHeadersView = this.getView('columnHeadersView'); this.rowsView = this.getView('rowsView'); this.aiAssistantController = this.getController('aiAssistant'); + this.handleMessageStorePushContext = this.handleMessageStorePush.bind(this); } private getAIChatConfig(): AIChatOptions { @@ -97,9 +103,30 @@ export class AIAssistantView extends View { }); } + private handleMessageStorePush(changes: DataChange[]): void { + const userId = this.aiChatInstance.getUserId(); + + changes.forEach(({ type, data }) => { + if (type === 'insert' && data && isUserMessage(data, userId)) { + this.executeRequest(data); + } + }); + } + + private subscribeMessageStorePush(messageStore: ArrayStore): void { + messageStore.off('push', this.handleMessageStorePushContext); + messageStore.on('push', this.handleMessageStorePushContext); + } + private getAIChatOptions(): ChatProperties { + const messageStore = this.aiAssistantController.getMessageStore(); + + this.subscribeMessageStorePush(messageStore); + return { - dataSource: this.aiAssistantController.getMessageDataSource(), + dataSource: { + store: messageStore, + }, reloadOnChange: true, onMessageEntered: (e): void => { this.executeRequest(e.message); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/utils.ts index ccf7c7bf5a3d..b87ced00d4c5 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/utils.ts @@ -8,6 +8,11 @@ export const isAIMessage = ( message: Message, ): message is AIMessage => message.author?.id === AI_ASSISTANT_AUTHOR_ID; +export const isUserMessage = ( + message: Message, + userId: string, +): boolean => message.author?.id === userId; + export const isEnabledOption = (optionName: string, value: unknown): boolean => optionName.startsWith('aiAssistant.enabled') || (optionName === 'aiAssistant' && isObject(value) && 'enabled' in value); 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 caf36b8da0c0..8f4c735f491c 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 @@ -937,4 +937,33 @@ describe('AIChat', () => { }).not.toThrow(); }); }); + + describe('getUserId', () => { + it('should return user id from chat instance', () => { + mockChatInstance.option.mockImplementation((name: string) => { + if (name === 'user.id') return 'user-123'; + return undefined; + }); + + const { aiChat } = createAIChat(); + triggerContentTemplate(); + + expect(aiChat.getUserId()).toBe('user-123'); + }); + + it('should return empty string when chatInstance is not initialized', () => { + const { aiChat } = createAIChat(); + + expect(aiChat.getUserId()).toBe(''); + }); + + it('should return empty string when user.id is not set', () => { + mockChatInstance.option.mockReturnValue(undefined); + + const { aiChat } = createAIChat(); + triggerContentTemplate(); + + expect(aiChat.getUserId()).toBe(''); + }); + }); }); 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 240e04edd501..155ccb35b83f 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 @@ -333,4 +333,8 @@ export class AIChat { // eslint-disable-next-line @typescript-eslint/no-floating-promises dataSource?.reload(); } + + public getUserId(): string { + return this.chatInstance?.option('user.id') as string ?? ''; + } } From 85a146083fa78ffd1f95217170922f24b19ee657 Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Fri, 15 May 2026 00:04:14 +0400 Subject: [PATCH 2/2] Unsubscribe from the store push event on dispose --- .../__tests__/ai_assistant_view.test.ts | 26 +++++++++++++++-- .../ai_assistant/ai_assistant_view.ts | 29 ++++++++++++------- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view.test.ts index a0ea460a3501..46eb167bd1b1 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view.test.ts @@ -162,7 +162,7 @@ describe('AIAssistantView', () => { expect(mockAIAssistantController.getMessageStore).toHaveBeenCalledTimes(1); expect(aiChatConfig.chatOptions).toEqual(expect.objectContaining({ - dataSource: expect.objectContaining({ store: mockMessageStore }), + dataSource: mockMessageStore, reloadOnChange: true, onMessageEntered: expect.any(Function), })); @@ -234,6 +234,26 @@ describe('AIAssistantView', () => { }); }); + describe('dispose', () => { + it('should unsubscribe from store push event', () => { + const onSpy = jest.spyOn(mockMessageStore, 'on'); + const { aiAssistantView } = createAIAssistantView(); + const offSpy = jest.spyOn(mockMessageStore, 'off'); + + aiAssistantView.dispose(); + + const onPushCall = (onSpy.mock.calls as any[][]).find((call) => call[0] === 'push'); + const offPushCall = (offSpy.mock.calls as any[][]).find((call) => call[0] === 'push'); + + expect(onPushCall).toBeDefined(); + expect(offPushCall).toBeDefined(); + expect((offPushCall as any[])[1]).toBe((onPushCall as any[])[1]); + + offSpy.mockRestore(); + onSpy.mockRestore(); + }); + }); + describe('isShown', () => { it('should delegate to AIChat isShown method', () => { const { aiAssistantView } = createAIAssistantView(); @@ -483,9 +503,9 @@ describe('AIAssistantView', () => { return pushCall[1] as (changes: any[]) => void; }; - it('should subscribe to store push event during render', () => { + it('should subscribe to store push event during init', () => { const onSpy = jest.spyOn(mockMessageStore, 'on'); - createAIAssistantView(); + createAIAssistantView({ render: false }); expect(onSpy).toHaveBeenCalledWith('push', expect.any(Function)); onSpy.mockRestore(); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view.ts index dec2d6f42449..cca5ac0776c7 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view.ts @@ -29,6 +29,8 @@ export class AIAssistantView extends View { private aiAssistantController!: AIAssistantController; + private messageStore?: ArrayStore; + private columnHeadersView!: ColumnHeadersView; private rowsView!: RowsView; @@ -41,7 +43,11 @@ export class AIAssistantView extends View { this.columnHeadersView = this.getView('columnHeadersView'); this.rowsView = this.getView('rowsView'); this.aiAssistantController = this.getController('aiAssistant'); + this.messageStore = this.aiAssistantController.getMessageStore(); this.handleMessageStorePushContext = this.handleMessageStorePush.bind(this); + + this.unsubscribeMessageStorePush(); + this.subscribeMessageStorePush(); } private getAIChatConfig(): AIChatOptions { @@ -113,20 +119,17 @@ export class AIAssistantView extends View { }); } - private subscribeMessageStorePush(messageStore: ArrayStore): void { - messageStore.off('push', this.handleMessageStorePushContext); - messageStore.on('push', this.handleMessageStorePushContext); + private unsubscribeMessageStorePush(): void { + this.messageStore?.off('push', this.handleMessageStorePushContext); } - private getAIChatOptions(): ChatProperties { - const messageStore = this.aiAssistantController.getMessageStore(); - - this.subscribeMessageStorePush(messageStore); + private subscribeMessageStorePush(): void { + this.messageStore?.on('push', this.handleMessageStorePushContext); + } + private getAIChatOptions(): ChatProperties { return { - dataSource: { - store: messageStore, - }, + dataSource: this.messageStore, reloadOnChange: true, onMessageEntered: (e): void => { this.executeRequest(e.message); @@ -143,6 +146,12 @@ export class AIAssistantView extends View { } } + public dispose(): void { + this.unsubscribeMessageStorePush(); + this.messageStore = undefined; + super.dispose(); + } + public optionChanged(args: OptionChanged): void { if (args.name === 'aiAssistant') { const enabledChanged = isEnabledOption(args.fullName, args.value);