From 3c1880ca208713d69c820cd878973e1a4131aba3 Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Thu, 30 Apr 2026 06:23:45 +0400 Subject: [PATCH 1/4] feat(grids): add disabled state for AI Assistant during pending request --- .../base/gridBase/layout/aiChat/_index.scss | 11 + .../__tests__/ai_assistant_controller.test.ts | 56 ++++- .../__tests__/ai_assistant_view.test.ts | 77 ++++++ .../ai_assistant/ai_assistant_controller.ts | 41 +-- .../ai_assistant/ai_assistant_view.ts | 10 +- .../grids/grid_core/ai_chat/ai_chat.test.ts | 234 ++++++++++++++++++ .../grids/grid_core/ai_chat/ai_chat.ts | 46 +++- .../grids/grid_core/ai_chat/const.ts | 4 + .../js/__internal/grids/grid_core/m_utils.ts | 6 +- 9 files changed, 460 insertions(+), 25 deletions(-) diff --git a/packages/devextreme-scss/scss/widgets/base/gridBase/layout/aiChat/_index.scss b/packages/devextreme-scss/scss/widgets/base/gridBase/layout/aiChat/_index.scss index 33ad964529d0..c458e54ea0c8 100644 --- a/packages/devextreme-scss/scss/widgets/base/gridBase/layout/aiChat/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/gridBase/layout/aiChat/_index.scss @@ -66,3 +66,14 @@ bottom: 0; line-height: 0; } + +.dx-ai-chat--disabled { + .dx-chat-messagebox, .dx-ai-chat__message-regenerate-button { + opacity: 0.5; + pointer-events: none; + } + + .dx-ai-chat__message-regenerate-button { + cursor: default; + } +} 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 488743b57431..b0eb50808d10 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 @@ -82,6 +82,7 @@ describe('AIAssistantController', () => { const timestamp = '2026-04-16T10:00:00.000Z'; const expectedTimestamp = Date.parse(timestamp); + // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, text: 'Generate values', @@ -104,6 +105,7 @@ describe('AIAssistantController', () => { it('should keep message as pending when AI integration is not configured', async () => { const controller = createController(); + // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, text: 'Generate values', @@ -124,6 +126,7 @@ describe('AIAssistantController', () => { 'aiAssistant.aiIntegration': mockAIIntegration, }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, text: 'Generate values', @@ -157,7 +160,7 @@ describe('AIAssistantController', () => { author: { id: 'user', name: 'User' }, text: 'Generate values', timestamp: '2026-04-16T10:00:00.000Z', - } as Message); + } as Message).catch(() => {}); sendRequestCallbacks.onError?.(new Error('Network error')); @@ -180,7 +183,7 @@ describe('AIAssistantController', () => { author: { id: 'user', name: 'User' }, text: 'Generate values', timestamp: '2026-04-16T10:00:00.000Z', - } as Message); + } as Message).catch(() => {}); const response = {} as ExecuteGridAssistantCommandResult; @@ -197,5 +200,54 @@ describe('AIAssistantController', () => { }), ]); }); + + it('should resolve promise when command succeeds', async () => { + const controller = createController({ + 'aiAssistant.aiIntegration': mockAIIntegration, + }); + + const promise = controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Generate values', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + + const actions = [{ name: 'sort', args: { column: 'Name' } }]; + sendRequestCallbacks.onComplete?.({ actions }); + + await expect(promise).resolves.toBeUndefined(); + }); + + it('should reject promise when onError is called', async () => { + const controller = createController({ + 'aiAssistant.aiIntegration': mockAIIntegration, + }); + + const promise = controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Generate values', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + + sendRequestCallbacks.onError?.(new Error('Network error')); + + await expect(promise).rejects.toThrow('Network error'); + }); + + it('should reject promise when response has no actions', async () => { + const controller = createController({ + 'aiAssistant.aiIntegration': mockAIIntegration, + }); + + const promise = controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Generate values', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + + sendRequestCallbacks.onComplete?.({} as ExecuteGridAssistantCommandResult); + + await expect(promise).rejects.toThrow('Default error message'); + }); }); }); 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 0f31e20ad09f..032cdbfd4ed0 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 @@ -283,6 +283,7 @@ describe('AIAssistantView', () => { describe('chat event handlers', () => { describe('onMessageEntered', () => { it('should send request to AI with the entered message', () => { + mockAIAssistantController.sendRequestToAI.mockReturnValue(Promise.resolve()); createAIAssistantView(); const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions; @@ -296,6 +297,82 @@ describe('AIAssistantView', () => { expect(mockAIAssistantController.sendRequestToAI).toHaveBeenCalledTimes(1); expect(mockAIAssistantController.sendRequestToAI).toHaveBeenCalledWith(message); }); + + it('should not send request when chat is disabled', () => { + createAIAssistantView(); + + const aiChatInstance = (AIChat as jest.Mock) + .mock.results[0].value as { isDisabled: jest.Mock; setDisabled: jest.Mock }; + aiChatInstance.isDisabled.mockReturnValue(true); + + const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions; + const message = { + author: { id: 'user', name: 'User' }, + text: 'Generate summary', + }; + + aiChatConfig.chatOptions?.onMessageEntered?.({ message } as any); + + expect(mockAIAssistantController.sendRequestToAI).not.toHaveBeenCalled(); + }); + + it('should call setDisabled(true) before sending request', () => { + mockAIAssistantController.sendRequestToAI.mockReturnValue(Promise.resolve()); + createAIAssistantView(); + + const aiChatInstance = (AIChat as jest.Mock) + .mock.results[0].value as { setDisabled: jest.Mock }; + + const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions; + const message = { + author: { id: 'user', name: 'User' }, + text: 'Generate summary', + }; + + aiChatConfig.chatOptions?.onMessageEntered?.({ message } as any); + + expect(aiChatInstance.setDisabled).toHaveBeenCalledWith(true); + }); + + it('should call setDisabled(false) after request completes successfully', async () => { + mockAIAssistantController.sendRequestToAI.mockReturnValue(Promise.resolve()); + createAIAssistantView(); + + const aiChatInstance = (AIChat as jest.Mock) + .mock.results[0].value as { setDisabled: jest.Mock }; + + const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions; + const message = { + author: { id: 'user', name: 'User' }, + text: 'Generate summary', + }; + + aiChatConfig.chatOptions?.onMessageEntered?.({ message } as any); + await Promise.resolve(); + + expect(aiChatInstance.setDisabled).toHaveBeenLastCalledWith(false); + }); + + it('should call setDisabled(false) after request fails', async () => { + mockAIAssistantController.sendRequestToAI.mockReturnValue( + Promise.reject(new Error('Network error')), + ); + createAIAssistantView(); + + const aiChatInstance = (AIChat as jest.Mock) + .mock.results[0].value as { setDisabled: jest.Mock }; + + const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions; + const message = { + author: { id: 'user', name: 'User' }, + text: 'Generate summary', + }; + + aiChatConfig.chatOptions?.onMessageEntered?.({ message } as any); + await Promise.resolve(); + + expect(aiChatInstance.setDisabled).toHaveBeenLastCalledWith(false); + }); }); }); 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 b29ff39703bc..8b0917ec3a26 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 @@ -102,26 +102,31 @@ export class AIAssistantController extends Controller { }; } - public sendRequestToAI(message: Message): void { + public sendRequestToAI(message: Message): Promise { const aiMessageId = this.createPendingAIMessage(message); - this.aiAssistantIntegrationController?.sendRequest(message.text, { - onComplete: (response: ExecuteGridAssistantCommandResult): void => { - fromPromise(this.processResponse(response)) - .done((commands: CommandResults) => { - this.completeAIMessage(aiMessageId, commands); - }) - .fail((errorMessage) => { - const error = errorMessage instanceof Error - ? errorMessage - : new Error(String(errorMessage)); - - this.failAIMessage(aiMessageId, error); - }); - }, - onError: (error: Error): void => { - this.failAIMessage(aiMessageId, error); - }, + return new Promise((resolve, reject) => { + this.aiAssistantIntegrationController?.sendRequest(message.text, { + onComplete: (response: ExecuteGridAssistantCommandResult): void => { + fromPromise(this.processResponse(response)) + .done((commands: CommandResults) => { + this.completeAIMessage(aiMessageId, commands); + resolve(); + }) + .fail((errorMessage) => { + const error = errorMessage instanceof Error + ? errorMessage + : new Error(String(errorMessage)); + + this.failAIMessage(aiMessageId, error); + reject(error); + }); + }, + onError: (error: Error): void => { + this.failAIMessage(aiMessageId, error); + reject(error); + }, + }); }); } 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 4de99df0b311..a198424c276a 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 @@ -3,6 +3,7 @@ import type { Callback } from '@js/core/utils/callbacks'; import { getHeight } from '@js/core/utils/size'; import type { Properties as ChatProperties } from '@js/ui/chat'; import type { Properties as PopupProperties } from '@js/ui/popup'; +import { fromPromise } from '@ts/core/utils/m_deferred'; import { AI_ASSISTANT_POPUP_OFFSET } from '@ts/grids/grid_core/ai_assistant/const'; import { isChatOptions, @@ -90,7 +91,14 @@ export class AIAssistantView extends View { dataSource: this.aiAssistantController.getMessageDataSource(), reloadOnChange: true, onMessageEntered: (e): void => { - this.aiAssistantController.sendRequestToAI(e.message); + if (this.aiChatInstance?.isDisabled()) { + return; + } + + this.aiChatInstance?.setDisabled(true); + fromPromise(this.aiAssistantController.sendRequestToAI(e.message)).always(() => { + this.aiChatInstance?.setDisabled(false); + }); }, ...this.option('aiAssistant.chat'), }; 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 9a107750799f..4d1c4ad3a0ad 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 @@ -20,14 +20,28 @@ import { } from './const'; import type { AIChatOptions, CommandResults } from './types'; +const mockWidgetInstance = { + option: jest.fn(), +}; + +jest.mock('../m_utils', () => ({ + __esModule: true, + default: { + getWidgetInstance: jest.fn(() => mockWidgetInstance), + }, +})); + const mockPopupInstance = { toggle: jest.fn<() => Promise>().mockResolvedValue(true), hide: jest.fn<() => Promise>().mockResolvedValue(true), option: jest.fn<(name: string) => unknown>().mockReturnValue(false), }; +const mockChatElement = $('
'); + const mockChatInstance = { option: jest.fn(), + $element: jest.fn(() => mockChatElement), }; const createComponentMock = jest.fn(( @@ -88,6 +102,9 @@ const getChatConfig = (): any => { const beforeTest = (): void => { jest.clearAllMocks(); + mockChatElement.removeClass(CLASSES.disabled); + mockChatElement.empty(); + mockWidgetInstance.option.mockClear(); }; const afterTest = (): void => { @@ -662,4 +679,221 @@ describe('AIChat', () => { }).not.toThrow(); }); }); + + describe('disabled state', () => { + describe('setDisabled', () => { + it('should add disabled class to chat element', () => { + const { aiChat } = createAIChat(); + triggerContentTemplate(); + + aiChat.setDisabled(true); + + expect(mockChatElement.hasClass(CLASSES.disabled)).toBe(true); + }); + + it('should remove disabled class from chat element when set to false', () => { + const { aiChat } = createAIChat(); + triggerContentTemplate(); + + aiChat.setDisabled(true); + aiChat.setDisabled(false); + + expect(mockChatElement.hasClass(CLASSES.disabled)).toBe(false); + }); + + it('should disable text area widget', () => { + const { aiChat } = createAIChat(); + triggerContentTemplate(); + mockChatElement.append($('
').addClass('dx-textarea')); + + aiChat.setDisabled(true); + + expect(mockWidgetInstance.option).toHaveBeenCalledWith('disabled', true); + }); + + it('should enable text area widget when set to false', () => { + const { aiChat } = createAIChat(); + triggerContentTemplate(); + mockChatElement.append($('
').addClass('dx-textarea')); + + aiChat.setDisabled(true); + aiChat.setDisabled(false); + + expect(mockWidgetInstance.option).toHaveBeenCalledWith('disabled', false); + }); + + it('should disable speech-to-text widget', () => { + const { aiChat } = createAIChat(); + triggerContentTemplate(); + mockChatElement.append($('
').addClass('dx-speech-to-text')); + + aiChat.setDisabled(true); + + expect(mockWidgetInstance.option).toHaveBeenCalledWith('disabled', true); + }); + + it('should enable speech-to-text widget when set to false', () => { + const { aiChat } = createAIChat(); + triggerContentTemplate(); + mockChatElement.append($('
').addClass('dx-speech-to-text')); + + aiChat.setDisabled(true); + aiChat.setDisabled(false); + + expect(mockWidgetInstance.option).toHaveBeenCalledWith('disabled', false); + }); + + it('should disable clear button via popup toolbarItems option', () => { + const onChatCleared = jest.fn(); + const { aiChat } = createAIChat({ onChatCleared }); + triggerContentTemplate(); + + aiChat.setDisabled(true); + + expect(mockPopupInstance.option).toHaveBeenCalledWith('toolbarItems[0].options.disabled', true); + }); + + it('should enable clear button via popup toolbarItems option', () => { + const onChatCleared = jest.fn(); + const { aiChat } = createAIChat({ onChatCleared }); + triggerContentTemplate(); + + aiChat.setDisabled(true); + aiChat.setDisabled(false); + + expect(mockPopupInstance.option).toHaveBeenCalledWith('toolbarItems[0].options.disabled', false); + }); + + it('should not update popup toolbarItems when onChatCleared is not provided', () => { + const { aiChat } = createAIChat(); + triggerContentTemplate(); + + aiChat.setDisabled(true); + + expect(mockPopupInstance.option).not.toHaveBeenCalledWith( + 'toolbarItems[0].options.disabled', + expect.anything(), + ); + }); + + it('should not update when setting same disabled value', () => { + const { aiChat } = createAIChat(); + triggerContentTemplate(); + + aiChat.setDisabled(true); + mockPopupInstance.option.mockClear(); + mockWidgetInstance.option.mockClear(); + + aiChat.setDisabled(true); + + expect(mockPopupInstance.option).not.toHaveBeenCalled(); + expect(mockWidgetInstance.option).not.toHaveBeenCalled(); + }); + + it('should not throw when chatInstance is not created', () => { + const { aiChat } = createAIChat(); + + expect(() => { + aiChat.setDisabled(true); + }).not.toThrow(); + }); + }); + + describe('isDisabled', () => { + it('should return false by default', () => { + const { aiChat } = createAIChat(); + + expect(aiChat.isDisabled()).toBe(false); + }); + + it('should return true after setDisabled(true)', () => { + const { aiChat } = createAIChat(); + + aiChat.setDisabled(true); + + expect(aiChat.isDisabled()).toBe(true); + }); + + it('should return false after setDisabled(false)', () => { + const { aiChat } = createAIChat(); + + aiChat.setDisabled(true); + aiChat.setDisabled(false); + + expect(aiChat.isDisabled()).toBe(false); + }); + }); + + describe('regenerate button in disabled state', () => { + it('should not call onRegenerate when chat is disabled', () => { + const onRegenerate = jest.fn(); + const { aiChat } = createAIChat({ onRegenerate }); + triggerContentTemplate(); + + aiChat.setDisabled(true); + + const chatConfig = getChatConfig(); + const container = document.createElement('div'); + + chatConfig.messageTemplate({ + message: { + author: { id: AI_ASSISTANT_AUTHOR_ID, name: 'AI Assistant' }, + text: 'Error occurred', + status: 'failure', + }, + }, container); + + const regenerateButton = container.querySelector(`.${CLASSES.messageRegenerateButton}`) as HTMLElement; + regenerateButton.click(); + + expect(onRegenerate).not.toHaveBeenCalled(); + }); + + it('should render regenerate button when chat is disabled', () => { + const onRegenerate = jest.fn(); + const { aiChat } = createAIChat({ onRegenerate }); + triggerContentTemplate(); + + aiChat.setDisabled(true); + + const chatConfig = getChatConfig(); + const container = document.createElement('div'); + + chatConfig.messageTemplate({ + message: { + author: { id: AI_ASSISTANT_AUTHOR_ID, name: 'AI Assistant' }, + text: 'Error occurred', + status: 'failure', + }, + }, container); + + expect(container.querySelector(`.${CLASSES.messageRegenerateButton}`)).not.toBeNull(); + }); + + it('should call onRegenerate when chat is re-enabled', () => { + const onRegenerate = jest.fn(); + const { aiChat } = createAIChat({ onRegenerate }); + triggerContentTemplate(); + + aiChat.setDisabled(true); + aiChat.setDisabled(false); + + const chatConfig = getChatConfig(); + const container = document.createElement('div'); + + chatConfig.messageTemplate({ + message: { + author: { id: AI_ASSISTANT_AUTHOR_ID, name: 'AI Assistant' }, + text: 'Error occurred', + status: 'failure', + }, + }, container); + + const regenerateButton = container.querySelector(`.${CLASSES.messageRegenerateButton}`) as HTMLElement; + regenerateButton.click(); + + expect(onRegenerate).toHaveBeenCalledTimes(1); + }); + }); + }); }); 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 bd7b8a54c5a6..2c3796bbb8ee 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 @@ -15,6 +15,7 @@ import { import ProgressBar from '@ts/ui/m_progress_bar'; import Popup from '@ts/ui/popup/m_popup'; +import gridCoreUtils from '../m_utils'; import { CLASSES, CLEAR_CHAT_ICON, DEFAULT_CHAT_OPTIONS, @@ -36,6 +37,8 @@ export class AIChat { private chatInstance?: Chat; + private disabled = false; + constructor( private options: AIChatOptions, ) { @@ -115,6 +118,7 @@ export class AIChat { widget: 'dxButton', toolbar: 'top', location: 'after', + cssClass: `${CLASSES.clearChatButton}`, options: { icon: CLEAR_CHAT_ICON, hint: messageLocalization.format('dxDataGrid-aiAssistantClearButtonText'), @@ -159,7 +163,11 @@ export class AIChat { .addClass(`dx-icon dx-icon-${REGENERATE_ICON} ${CLASSES.messageRegenerateButton}`) .appendTo($row); - eventsEngine.on($button, clickEventName, () => this.options.onRegenerate?.()); + eventsEngine.on($button, clickEventName, () => { + if (!this.disabled) { + this.options.onRegenerate?.(); + } + }); } } @@ -247,6 +255,22 @@ export class AIChat { .appendTo($container); } + private setTextAreaDisabled(disabled: boolean): void { + const $textArea = this.chatInstance?.$element().find(CLASSES.textArea); + + if ($textArea?.length) { + gridCoreUtils.getWidgetInstance($textArea)?.option('disabled', disabled); + } + } + + private setSpeechToTextDisabled(disabled: boolean): void { + const $speechToText = this.chatInstance?.$element().find(CLASSES.speechToTextButton); + + if ($speechToText?.length) { + gridCoreUtils.getWidgetInstance($speechToText)?.option('disabled', disabled); + } + } + public updateOptions(options: AIChatOptions, updatePopup: boolean, updateChat: boolean): void { this.options = options; @@ -271,6 +295,26 @@ export class AIChat { return !!this.popupInstance?.option('visible'); } + public setDisabled(disabled: boolean): void { + if (this.disabled === disabled) { + return; + } + + this.disabled = disabled; + this.chatInstance?.$element().toggleClass(CLASSES.disabled, disabled); + + this.setTextAreaDisabled(disabled); + this.setSpeechToTextDisabled(disabled); + + if (this.options.onChatCleared) { + this.popupInstance.option('toolbarItems[0].options.disabled', disabled); + } + } + + public isDisabled(): boolean { + return this.disabled; + } + public renderAIMessage(message: Message, container: HTMLElement): void { const $message = $('
') .addClass(`${CLASSES.message} ${getMessageStateClass(message.status)}`) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/const.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/const.ts index 9aa1b824acb7..0ef725fddbd0 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/const.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/const.ts @@ -38,6 +38,10 @@ export const CLASSES = { messageHeaderRow: 'dx-ai-chat__message-header-row', messageRegenerateButton: 'dx-ai-chat__message-regenerate-button', messageProgressBar: 'dx-ai-chat__message-progressbar', + clearChatButton: 'dx-ai-chat__clear-button', + disabled: 'dx-ai-chat--disabled', + textArea: '.dx-textarea', + speechToTextButton: '.dx-speech-to-text', }; export const CLEAR_CHAT_ICON = 'clearhistory'; diff --git a/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts b/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts index 4ca57b332f3f..925e1544cc69 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts @@ -144,9 +144,9 @@ const getSummaryText = function (summaryItem, summaryTexts) { }; const getWidgetInstance = function ($element) { - const editorData = $element.data && $element.data(); - const dxComponents = editorData && editorData.dxComponents; - const widgetName = dxComponents && dxComponents[0]; + const editorData = $element?.data(); + const dxComponents = editorData?.dxComponents; + const widgetName = dxComponents?.[0]; return widgetName && editorData[widgetName]; }; From 12a27f971976761aefa726c54ed05a133ef353b3 Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Thu, 30 Apr 2026 12:45:15 +0400 Subject: [PATCH 2/4] Fix copilot comments --- .../grids/grid_core/ai_chat/ai_chat.ts | 19 +++++++++++++------ .../grids/grid_core/ai_chat/const.ts | 4 ++-- 2 files changed, 15 insertions(+), 8 deletions(-) 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 2c3796bbb8ee..3b67df64993c 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 @@ -7,6 +7,7 @@ import type { Message, Properties as ChatProperties } from '@js/ui/chat'; import Chat from '@js/ui/chat'; import type { Properties as PopupProperties, ToolbarItem } from '@js/ui/popup'; import { MessageStatus } from '@ts/grids/grid_core/ai_assistant/const'; +import type Button from '@ts/ui/button'; import { CHAT_MESSAGELIST_EMPTY_IMAGE_CLASS, CHAT_MESSAGELIST_EMPTY_MESSAGE_CLASS, @@ -37,6 +38,8 @@ export class AIChat { private chatInstance?: Chat; + private clearChatButtonInstance?: Button; + private disabled = false; constructor( @@ -123,6 +126,9 @@ export class AIChat { icon: CLEAR_CHAT_ICON, hint: messageLocalization.format('dxDataGrid-aiAssistantClearButtonText'), onClick: onChatCleared, + onInitialized: (e): void => { + this.clearChatButtonInstance = e.component; + }, }, }; } @@ -256,7 +262,7 @@ export class AIChat { } private setTextAreaDisabled(disabled: boolean): void { - const $textArea = this.chatInstance?.$element().find(CLASSES.textArea); + const $textArea = this.chatInstance?.$element().find(`.${CLASSES.textArea}`); if ($textArea?.length) { gridCoreUtils.getWidgetInstance($textArea)?.option('disabled', disabled); @@ -264,13 +270,17 @@ export class AIChat { } private setSpeechToTextDisabled(disabled: boolean): void { - const $speechToText = this.chatInstance?.$element().find(CLASSES.speechToTextButton); + const $speechToText = this.chatInstance?.$element().find(`.${CLASSES.speechToTextButton}`); if ($speechToText?.length) { gridCoreUtils.getWidgetInstance($speechToText)?.option('disabled', disabled); } } + private setClearChatButtonDisabled(disabled: boolean): void { + this.clearChatButtonInstance?.option('disabled', disabled); + } + public updateOptions(options: AIChatOptions, updatePopup: boolean, updateChat: boolean): void { this.options = options; @@ -305,10 +315,7 @@ export class AIChat { this.setTextAreaDisabled(disabled); this.setSpeechToTextDisabled(disabled); - - if (this.options.onChatCleared) { - this.popupInstance.option('toolbarItems[0].options.disabled', disabled); - } + this.setClearChatButtonDisabled(disabled); } public isDisabled(): boolean { diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/const.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/const.ts index 0ef725fddbd0..145447e3e691 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/const.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/const.ts @@ -40,8 +40,8 @@ export const CLASSES = { messageProgressBar: 'dx-ai-chat__message-progressbar', clearChatButton: 'dx-ai-chat__clear-button', disabled: 'dx-ai-chat--disabled', - textArea: '.dx-textarea', - speechToTextButton: '.dx-speech-to-text', + textArea: 'dx-textarea', + speechToTextButton: 'dx-speech-to-text', }; export const CLEAR_CHAT_ICON = 'clearhistory'; From 44442ab54986513583431ec49be62ff8133b683a Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Thu, 30 Apr 2026 13:31:39 +0400 Subject: [PATCH 3/4] Fix jest tests --- .../__tests__/ai_assistant_controller.test.ts | 12 ++++++--- .../grids/grid_core/ai_chat/ai_chat.test.ts | 26 ++++++++++++++----- 2 files changed, 28 insertions(+), 10 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 b0eb50808d10..3e46c2b926bd 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 @@ -156,11 +156,11 @@ describe('AIAssistantController', () => { 'aiAssistant.aiIntegration': mockAIIntegration, }); - controller.sendRequestToAI({ + const promise = controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, text: 'Generate values', timestamp: '2026-04-16T10:00:00.000Z', - } as Message).catch(() => {}); + } as Message); sendRequestCallbacks.onError?.(new Error('Network error')); @@ -172,6 +172,8 @@ describe('AIAssistantController', () => { text: 'Network error', }), ]); + + await expect(promise).rejects.toThrow('Network error'); }); it('should fail message when response has no actions', async () => { @@ -179,11 +181,11 @@ describe('AIAssistantController', () => { 'aiAssistant.aiIntegration': mockAIIntegration, }); - controller.sendRequestToAI({ + const promise = controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, text: 'Generate values', timestamp: '2026-04-16T10:00:00.000Z', - } as Message).catch(() => {}); + } as Message); const response = {} as ExecuteGridAssistantCommandResult; @@ -199,6 +201,8 @@ describe('AIAssistantController', () => { text: 'Default error message', }), ]); + + await expect(promise).rejects.toThrow('Default error message'); }); it('should resolve promise when command succeeds', async () => { 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 4d1c4ad3a0ad..cc44fc2f04a6 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 @@ -44,11 +44,24 @@ const mockChatInstance = { $element: jest.fn(() => mockChatElement), }; +const mockClearChatButtonInstance = { + option: jest.fn(), +}; + const createComponentMock = jest.fn(( _el: any, Widget: any, + options?: any, ): any => { if (Widget === Popup) { + const toolbarItems = options?.toolbarItems; + + if (toolbarItems) { + toolbarItems.forEach((item: any) => { + item.options?.onInitialized?.({ component: mockClearChatButtonInstance }); + }); + } + return mockPopupInstance; } if (Widget === Chat) { @@ -105,6 +118,7 @@ const beforeTest = (): void => { mockChatElement.removeClass(CLASSES.disabled); mockChatElement.empty(); mockWidgetInstance.option.mockClear(); + mockClearChatButtonInstance.option.mockClear(); }; const afterTest = (): void => { @@ -750,7 +764,7 @@ describe('AIChat', () => { aiChat.setDisabled(true); - expect(mockPopupInstance.option).toHaveBeenCalledWith('toolbarItems[0].options.disabled', true); + expect(mockClearChatButtonInstance.option).toHaveBeenCalledWith('disabled', true); }); it('should enable clear button via popup toolbarItems option', () => { @@ -761,7 +775,7 @@ describe('AIChat', () => { aiChat.setDisabled(true); aiChat.setDisabled(false); - expect(mockPopupInstance.option).toHaveBeenCalledWith('toolbarItems[0].options.disabled', false); + expect(mockClearChatButtonInstance.option).toHaveBeenCalledWith('disabled', false); }); it('should not update popup toolbarItems when onChatCleared is not provided', () => { @@ -770,8 +784,8 @@ describe('AIChat', () => { aiChat.setDisabled(true); - expect(mockPopupInstance.option).not.toHaveBeenCalledWith( - 'toolbarItems[0].options.disabled', + expect(mockClearChatButtonInstance.option).not.toHaveBeenCalledWith( + 'disabled', expect.anything(), ); }); @@ -781,12 +795,12 @@ describe('AIChat', () => { triggerContentTemplate(); aiChat.setDisabled(true); - mockPopupInstance.option.mockClear(); + mockClearChatButtonInstance.option.mockClear(); mockWidgetInstance.option.mockClear(); aiChat.setDisabled(true); - expect(mockPopupInstance.option).not.toHaveBeenCalled(); + expect(mockClearChatButtonInstance.option).not.toHaveBeenCalled(); expect(mockWidgetInstance.option).not.toHaveBeenCalled(); }); From f1cc3799a41e71b856f80b03df2310162777357a Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Tue, 5 May 2026 19:47:14 +0400 Subject: [PATCH 4/4] Fix comments --- .../widgets/base/gridBase/layout/aiChat/_index.scss | 11 ++--------- .../widgets/fluent/gridBase/layout/aiChat/_index.scss | 5 +++++ .../generic/gridBase/layout/aiChat/_index.scss | 5 +++++ .../material/gridBase/layout/aiChat/_index.scss | 5 +++++ .../js/__internal/grids/grid_core/ai_chat/const.ts | 7 +++++-- 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/devextreme-scss/scss/widgets/base/gridBase/layout/aiChat/_index.scss b/packages/devextreme-scss/scss/widgets/base/gridBase/layout/aiChat/_index.scss index c458e54ea0c8..96dd1f988db9 100644 --- a/packages/devextreme-scss/scss/widgets/base/gridBase/layout/aiChat/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/gridBase/layout/aiChat/_index.scss @@ -67,13 +67,6 @@ line-height: 0; } -.dx-ai-chat--disabled { - .dx-chat-messagebox, .dx-ai-chat__message-regenerate-button { - opacity: 0.5; - pointer-events: none; - } - - .dx-ai-chat__message-regenerate-button { - cursor: default; - } +.dx-ai-chat--disabled .dx-ai-chat__message-regenerate-button { + pointer-events: none; } diff --git a/packages/devextreme-scss/scss/widgets/fluent/gridBase/layout/aiChat/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/gridBase/layout/aiChat/_index.scss index 2650293084e8..37b6b5f8c909 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/gridBase/layout/aiChat/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/gridBase/layout/aiChat/_index.scss @@ -2,6 +2,7 @@ @use '../../../colors' as *; @use '../../../button/colors' as *; @use './sizes' as *; +@use "../../../../base/widget" as *; @include ai-chat-messagelist-empty( $button-default-bg, $button-default-outlined-hover-bg, @@ -12,3 +13,7 @@ @include ai-chat-messagebubble-border($base-border-color); @include ai-chat-message-regenerate-button($button-normal-active-bg); @include ai-chat-message-icon($ai-chat-message-icon-size); + +.dx-ai-chat--disabled .dx-ai-chat__message-regenerate-button { + @include disabled-widget(); +} diff --git a/packages/devextreme-scss/scss/widgets/generic/gridBase/layout/aiChat/_index.scss b/packages/devextreme-scss/scss/widgets/generic/gridBase/layout/aiChat/_index.scss index 0ed8858df67a..15f4d095e138 100644 --- a/packages/devextreme-scss/scss/widgets/generic/gridBase/layout/aiChat/_index.scss +++ b/packages/devextreme-scss/scss/widgets/generic/gridBase/layout/aiChat/_index.scss @@ -2,6 +2,7 @@ @use "../../../colors" as *; @use "../../../button/colors" as *; @use './sizes' as *; +@use "../../../../base/widget" as *; @include ai-chat-messagelist-empty( $button-default-bg, $button-default-outlined-bg-hover, @@ -12,3 +13,7 @@ @include ai-chat-messagebubble-border($base-border-color); @include ai-chat-message-regenerate-button($button-normal-outlined-bg-active); @include ai-chat-message-icon($ai-chat-message-icon-size); + +.dx-ai-chat--disabled .dx-ai-chat__message-regenerate-button { + @include disabled-widget(); +} diff --git a/packages/devextreme-scss/scss/widgets/material/gridBase/layout/aiChat/_index.scss b/packages/devextreme-scss/scss/widgets/material/gridBase/layout/aiChat/_index.scss index b3d7c879389a..a406230481c2 100644 --- a/packages/devextreme-scss/scss/widgets/material/gridBase/layout/aiChat/_index.scss +++ b/packages/devextreme-scss/scss/widgets/material/gridBase/layout/aiChat/_index.scss @@ -2,6 +2,7 @@ @use "../../../colors" as *; @use "../../../button/colors" as *; @use './sizes' as *; +@use "../../../../base/widget" as *; @include ai-chat-messagelist-empty( $button-default-bg, $button-default-outlined-hover-bg, @@ -12,3 +13,7 @@ @include ai-chat-messagebubble-border($base-border-color); @include ai-chat-message-regenerate-button($button-normal-active-bg); @include ai-chat-message-icon($ai-chat-message-icon-size); + +.dx-ai-chat--disabled .dx-ai-chat__message-regenerate-button { + @include disabled-widget(); +} diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/const.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/const.ts index 145447e3e691..8deeefaf0b07 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_chat/const.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/const.ts @@ -1,3 +1,6 @@ +import { TEXTAREA_CLASS } from '@ts/ui/m_text_area'; +import { SPEECH_TO_TEXT_CLASS } from '@ts/ui/speech_to_text/speech_to_text'; + export const DEFAULT_POPUP_OPTIONS = { width: 400, minWidth: 400, @@ -40,8 +43,8 @@ export const CLASSES = { messageProgressBar: 'dx-ai-chat__message-progressbar', clearChatButton: 'dx-ai-chat__clear-button', disabled: 'dx-ai-chat--disabled', - textArea: 'dx-textarea', - speechToTextButton: 'dx-speech-to-text', + textArea: TEXTAREA_CLASS, + speechToTextButton: SPEECH_TO_TEXT_CLASS, }; export const CLEAR_CHAT_ICON = 'clearhistory';