').css('height', '400px').appendTo($container);
+
+ const mockColumnHeadersView = {
+ getHeight: jest.fn().mockReturnValue(50),
+ element: jest.fn().mockReturnValue($columnHeadersElement),
+ };
+ const mockRowsView = {
+ element: jest.fn().mockReturnValue($rowsViewElement),
+ };
+
const mockComponent = {
element: (): any => $container.get(0),
_createComponent: createComponentMock,
_controllers: {},
+ _views: {
+ columnHeadersView: mockColumnHeadersView,
+ rowsView: mockRowsView,
+ },
option: optionMock,
};
const aiAssistantView = new AIAssistantView(mockComponent);
+ aiAssistantView.init();
if (render) {
aiAssistantView.render($container);
}
@@ -114,13 +130,16 @@ describe('AIAssistantView', () => {
expect(AIChat).toHaveBeenCalledTimes(1);
});
- it('should pass container and createComponent to AIChat', () => {
+ it('should pass container, createComponent, popupOptions, chatOptions, and onChatCleared to AIChat', () => {
const { aiAssistantView } = createAIAssistantView();
expect(AIChat).toHaveBeenCalledWith(
expect.objectContaining({
container: aiAssistantView.element(),
createComponent: expect.any(Function),
+ popupOptions: expect.any(Object),
+ chatOptions: expect.any(Object),
+ onChatCleared: expect.any(Function),
}),
);
});
@@ -213,20 +232,158 @@ describe('AIAssistantView', () => {
});
describe('visibilityChanged', () => {
- it('should fire visibilityChanged callback when popup visibility changes', () => {
+ it('should fire visibilityChanged callback with true when popup onShowing is triggered', () => {
const { aiAssistantView } = createAIAssistantView();
const callback = jest.fn();
aiAssistantView.visibilityChanged?.add(callback);
const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions;
- aiChatConfig.onVisibilityChanged?.(true);
+ aiChatConfig.popupOptions?.onShowing?.({} as any);
expect(callback).toHaveBeenCalledWith(true);
+ });
- aiChatConfig.onVisibilityChanged?.(false);
+ it('should fire visibilityChanged callback with false when popup onHidden is triggered', () => {
+ const { aiAssistantView } = createAIAssistantView();
+ const callback = jest.fn();
+
+ aiAssistantView.visibilityChanged?.add(callback);
+
+ const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions;
+ aiChatConfig.popupOptions?.onHidden?.({} as any);
expect(callback).toHaveBeenCalledWith(false);
});
});
+
+ describe('optionChanged', () => {
+ it('should set handled to true for aiAssistant options', () => {
+ const { aiAssistantView } = createAIAssistantView();
+
+ const args = {
+ name: 'aiAssistant' as const,
+ fullName: 'aiAssistant.title' as const,
+ value: 'New Title',
+ previousValue: 'Old Title',
+ handled: false,
+ };
+
+ aiAssistantView.optionChanged(args);
+
+ expect(args.handled).toBe(true);
+ });
+
+ it('should call _invalidate when aiAssistant.enabled changes to true', () => {
+ const { aiAssistantView } = createAIAssistantView();
+ const invalidateSpy = jest.spyOn(aiAssistantView, '_invalidate' as any);
+
+ aiAssistantView.optionChanged({
+ name: 'aiAssistant' as const,
+ fullName: 'aiAssistant.enabled' as const,
+ value: true,
+ previousValue: false,
+ handled: false,
+ });
+
+ expect(invalidateSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call hide when aiAssistant.enabled changes to false', () => {
+ const { aiAssistantView, setEnabled } = createAIAssistantView();
+ const hideSpy = jest.spyOn(aiAssistantView, 'hide');
+
+ setEnabled(false);
+
+ aiAssistantView.optionChanged({
+ name: 'aiAssistant' as const,
+ fullName: 'aiAssistant.enabled' as const,
+ value: false,
+ previousValue: true,
+ handled: false,
+ });
+
+ expect(hideSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call updateOptions on aiChatInstance for title change', () => {
+ const { aiAssistantView } = createAIAssistantView();
+
+ const aiChatInstance = (AIChat as jest.Mock)
+ .mock.results[0].value as { updateOptions: jest.Mock };
+
+ aiAssistantView.optionChanged({
+ name: 'aiAssistant' as const,
+ fullName: 'aiAssistant.title' as const,
+ value: 'New Title',
+ previousValue: 'Old Title',
+ handled: false,
+ });
+
+ expect(aiChatInstance.updateOptions).toHaveBeenCalledTimes(1);
+ expect(aiChatInstance.updateOptions).toHaveBeenCalledWith(
+ expect.any(Object),
+ true,
+ false,
+ );
+ });
+
+ it('should call updateOptions on aiChatInstance for chat options change', () => {
+ const { aiAssistantView } = createAIAssistantView();
+
+ const aiChatInstance = (AIChat as jest.Mock)
+ .mock.results[0].value as { updateOptions: jest.Mock };
+
+ aiAssistantView.optionChanged({
+ name: 'aiAssistant' as const,
+ fullName: 'aiAssistant.chat' as const,
+ value: { speechToTextEnabled: false },
+ previousValue: {},
+ handled: false,
+ });
+
+ expect(aiChatInstance.updateOptions).toHaveBeenCalledTimes(1);
+ expect(aiChatInstance.updateOptions).toHaveBeenCalledWith(
+ expect.any(Object),
+ false,
+ true,
+ );
+ });
+
+ it('should call updateOptions with both flags when object value contains title and chat', () => {
+ const { aiAssistantView } = createAIAssistantView();
+
+ const aiChatInstance = (AIChat as jest.Mock)
+ .mock.results[0].value as { updateOptions: jest.Mock };
+
+ aiAssistantView.optionChanged({
+ name: 'aiAssistant' as const,
+ fullName: 'aiAssistant' as const,
+ value: { title: 'New title', chat: { speechToTextEnabled: false } },
+ previousValue: { title: 'Old title' },
+ handled: false,
+ });
+
+ expect(aiChatInstance.updateOptions).toHaveBeenCalledTimes(1);
+ expect(aiChatInstance.updateOptions).toHaveBeenCalledWith(
+ expect.any(Object),
+ true,
+ true,
+ );
+ });
+
+ it('should not throw when aiChatInstance is not created for non-enabled sub-options', () => {
+ const { aiAssistantView } = createAIAssistantView({ render: false });
+
+ expect(() => {
+ aiAssistantView.optionChanged({
+ name: 'aiAssistant' as const,
+ fullName: 'aiAssistant.title' as const,
+ value: 'New Title',
+ previousValue: 'Old Title',
+ handled: false,
+ });
+ }).not.toThrow();
+ });
+ });
});
diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view_controller.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view_controller.test.ts
index d823e44d9f90..ea24c78277e9 100644
--- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view_controller.test.ts
+++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view_controller.test.ts
@@ -11,13 +11,14 @@ import { AIAssistantViewController } from '../ai_assistant_view_controller';
interface MockVisibilityChangedCallback {
add: jest.Mock;
+ remove: jest.Mock;
fire: jest.Mock;
}
interface MockAIAssistantView {
toggle: jest.Mock<() => Promise
>;
hide: jest.Mock<() => Promise>;
- _invalidate: jest.Mock;
+ isShown: jest.Mock<() => boolean>;
visibilityChanged: MockVisibilityChangedCallback;
}
@@ -31,9 +32,10 @@ interface MockHeaderPanel {
const createMockAIAssistantView = (): MockAIAssistantView => ({
toggle: jest.fn<() => Promise>().mockResolvedValue(true),
hide: jest.fn<() => Promise>().mockResolvedValue(true),
- _invalidate: jest.fn(),
+ isShown: jest.fn<() => boolean>().mockReturnValue(false),
visibilityChanged: {
add: jest.fn(),
+ remove: jest.fn(),
fire: jest.fn(),
},
});
@@ -113,10 +115,21 @@ describe('AIAssistantViewController', () => {
expect(mockView.visibilityChanged.add).toHaveBeenCalledTimes(1);
expect(mockView.visibilityChanged.add).toHaveBeenCalledWith(expect.any(Function));
});
+
+ it('should call remove before add to prevent duplicate subscriptions', () => {
+ const { mockView } = createAIAssistantViewController();
+
+ const removeOrder = mockView.visibilityChanged.remove.mock.invocationCallOrder[0];
+ const addOrder = mockView.visibilityChanged.add.mock.invocationCallOrder[0];
+
+ expect(mockView.visibilityChanged.remove).toHaveBeenCalledTimes(1);
+ expect(mockView.visibilityChanged.remove).toHaveBeenCalledWith(expect.any(Function));
+ expect(removeOrder).toBeLessThan(addOrder);
+ });
});
describe('optionChanged', () => {
- it('should set handled to true for aiAssistant options', () => {
+ it('should set handled to true for aiAssistant.enabled option', () => {
const { controller } = createAIAssistantViewController();
const args = {
@@ -132,9 +145,41 @@ describe('AIAssistantViewController', () => {
expect(args.handled).toBe(true);
});
- it('should hide aiAssistantView when aiAssistant.enabled changes to false', () => {
+ it('should set handled to true for aiAssistant.title option', () => {
+ const { controller } = createAIAssistantViewController();
+
+ const args = {
+ name: 'aiAssistant' as const,
+ fullName: 'aiAssistant.title' as const,
+ value: 'New Title',
+ previousValue: 'Old Title',
+ handled: false,
+ };
+
+ controller.optionChanged(args);
+
+ expect(args.handled).toBe(true);
+ });
+
+ it('should not set handled for other aiAssistant sub-options', () => {
+ const { controller } = createAIAssistantViewController();
+
+ const args = {
+ name: 'aiAssistant' as const,
+ fullName: 'aiAssistant.popup' as const,
+ value: {},
+ previousValue: undefined,
+ handled: false,
+ };
+
+ controller.optionChanged(args);
+
+ expect(args.handled).toBe(false);
+ });
+
+ it('should sync toolbar item when aiAssistant.enabled changes to false', () => {
const options: Record = { 'aiAssistant.enabled': true };
- const { controller, mockView } = createAIAssistantViewController(options);
+ const { controller, mockHeaderPanel } = createAIAssistantViewController(options);
options['aiAssistant.enabled'] = false;
@@ -146,12 +191,12 @@ describe('AIAssistantViewController', () => {
handled: false,
});
- expect(mockView.hide).toHaveBeenCalledTimes(1);
+ expect(mockHeaderPanel.removeToolbarItem).toHaveBeenCalledTimes(1);
});
- it('should invalidate aiAssistantView when enabling', () => {
+ it('should sync toolbar item when aiAssistant.enabled changes to true', () => {
const options: Record = { 'aiAssistant.enabled': false };
- const { controller, mockView } = createAIAssistantViewController(options);
+ const { controller, mockHeaderPanel } = createAIAssistantViewController(options);
options['aiAssistant.enabled'] = true;
@@ -163,24 +208,61 @@ describe('AIAssistantViewController', () => {
handled: false,
});
- expect(mockView._invalidate).toHaveBeenCalledTimes(1);
+ expect(mockHeaderPanel.applyToolbarItem).toHaveBeenCalledTimes(1);
});
- it('should not invalidate aiAssistantView when disabling', () => {
+ it('should set handled to true when object value contains enabled', () => {
+ const options: Record = { 'aiAssistant.enabled': false };
+ const { controller, mockHeaderPanel } = createAIAssistantViewController(options);
+
+ options['aiAssistant.enabled'] = true;
+
+ const args = {
+ name: 'aiAssistant' as const,
+ fullName: 'aiAssistant' as const,
+ value: { enabled: true },
+ previousValue: { enabled: false },
+ handled: false,
+ };
+
+ controller.optionChanged(args);
+
+ expect(args.handled).toBe(true);
+ expect(mockHeaderPanel.applyToolbarItem).toHaveBeenCalledTimes(1);
+ });
+
+ it('should set handled to true when object value contains title', () => {
const options: Record = { 'aiAssistant.enabled': true };
- const { controller, mockView } = createAIAssistantViewController(options);
+ const { controller, mockHeaderPanel } = createAIAssistantViewController(options);
- options['aiAssistant.enabled'] = false;
+ const args = {
+ name: 'aiAssistant' as const,
+ fullName: 'aiAssistant' as const,
+ value: { title: 'New title', chat: { speechToTextEnabled: false } },
+ previousValue: { title: 'Old title' },
+ handled: false,
+ };
- controller.optionChanged({
+ controller.optionChanged(args);
+
+ expect(args.handled).toBe(true);
+ expect(mockHeaderPanel.applyToolbarItem).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not set handled when object value contains only chat/popup options', () => {
+ const { controller } = createAIAssistantViewController();
+
+ const args = {
name: 'aiAssistant' as const,
- fullName: 'aiAssistant.enabled' as const,
- value: false,
- previousValue: true,
+ fullName: 'aiAssistant' as const,
+ value: { chat: { speechToTextEnabled: false, showMessageTimestamp: true } },
+ previousValue: {},
handled: false,
- });
+ };
+
+ controller.optionChanged(args);
- expect(mockView._invalidate).not.toHaveBeenCalled();
+ expect(args.handled).toBe(false);
});
});
});
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
new file mode 100644
index 000000000000..0da3cac54f39
--- /dev/null
+++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/utils.test.ts
@@ -0,0 +1,88 @@
+import {
+ describe, expect, it,
+} from '@jest/globals';
+
+import {
+ isChatOptions,
+ isEnabledOption,
+ isPopupOptions,
+ isTitleOption,
+} from '../utils';
+
+describe('isEnabledOption', () => {
+ it('should return true for enabled option names', () => {
+ expect(isEnabledOption('aiAssistant.enabled', true)).toBe(true);
+ expect(isEnabledOption('aiAssistant', {
+ enabled: false,
+ title: 'AI Assistant',
+ })).toBe(true);
+ });
+
+ it('should return false for non-enabled option names', () => {
+ expect(isEnabledOption('aiAssistant.title', 'Title')).toBe(false);
+ expect(isEnabledOption('aiAssistant.popup', {})).toBe(false);
+ expect(isEnabledOption('aiAssistant', { title: 'Title' })).toBe(false);
+ expect(isEnabledOption('aiAssistant', 'string')).toBe(false);
+ });
+});
+
+describe('isTitleOption', () => {
+ it('should return true for title option names', () => {
+ expect(isTitleOption('aiAssistant.title', 'New Title')).toBe(true);
+ expect(isTitleOption('aiAssistant', {
+ title: 'New Title',
+ chat: { speechToTextEnabled: false },
+ })).toBe(true);
+ });
+
+ it('should return false for non-title option names', () => {
+ expect(isTitleOption('aiAssistant.enabled', true)).toBe(false);
+ expect(isTitleOption('aiAssistant.chat', {})).toBe(false);
+ expect(isTitleOption('aiAssistant', { enabled: true })).toBe(false);
+ expect(isTitleOption('aiAssistant', 'string')).toBe(false);
+ });
+});
+
+describe('isPopupOptions', () => {
+ it('should return true for popup option names', () => {
+ expect(isPopupOptions('aiAssistant.popup', {})).toBe(true);
+ expect(isPopupOptions('aiAssistant.popup.width', 400)).toBe(true);
+ expect(isPopupOptions('aiAssistant', { popup: { width: 400 } })).toBe(true);
+ expect(isPopupOptions('aiAssistant', {
+ popup: { width: 400 },
+ title: 'AI Assistant',
+ })).toBe(true);
+ });
+
+ it('should return false for non-popup option names', () => {
+ expect(isPopupOptions('aiAssistant.chat', {})).toBe(false);
+ expect(isPopupOptions('aiAssistant.enabled', true)).toBe(false);
+ expect(isPopupOptions('aiAssistant', { chat: {} })).toBe(false);
+ expect(isPopupOptions('aiAssistant', {
+ chat: { showAvatar: false },
+ title: 'AI Assistant',
+ })).toBe(false);
+ });
+});
+
+describe('isChatOptions', () => {
+ it('should return true for chat option names', () => {
+ expect(isChatOptions('aiAssistant.chat', {})).toBe(true);
+ expect(isChatOptions('aiAssistant.chat.showAvatar', false)).toBe(true);
+ expect(isChatOptions('aiAssistant', { chat: { showAvatar: false } })).toBe(true);
+ expect(isChatOptions('aiAssistant', {
+ chat: { speechToTextEnabled: false },
+ title: 'AI Assistant',
+ })).toBe(true);
+ });
+
+ it('should return false for non-chat option names', () => {
+ expect(isChatOptions('aiAssistant.popup', {})).toBe(false);
+ expect(isChatOptions('aiAssistant.enabled', true)).toBe(false);
+ expect(isChatOptions('aiAssistant', { popup: {} })).toBe(false);
+ expect(isChatOptions('aiAssistant', {
+ popup: { width: 400 },
+ title: 'AI Assistant',
+ })).toBe(false);
+ });
+});
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 e38cc94d009a..4e8e622ff306 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,32 +1,146 @@
+import type { PositionConfig } from '@js/common/core/animation';
+import { ArrayStore } from '@js/common/data';
import type { Callback } from '@js/core/utils/callbacks';
+import { getHeight } from '@js/core/utils/size';
+import { isString } from '@js/core/utils/type';
+import type { Message, Properties as ChatProperties } from '@js/ui/chat';
+import type { Properties as PopupProperties } from '@js/ui/popup';
+import { AI_ASSISTANT_POPUP_OFFSET } from '@ts/grids/grid_core/ai_assistant/const';
+import { isChatOptions, isEnabledOption, isTitleOption } from '@ts/grids/grid_core/ai_assistant/utils';
+import { isPopupOptions } from '@ts/grids/grid_core/ai_column/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 { AIChat } from '../ai_chat/ai_chat';
import type { AIChatOptions } from '../ai_chat/types';
import { View } from '../m_modules';
export class AIAssistantView extends View {
- private aiChatInstance!: AIChat;
+ private aiChatInstance?: AIChat;
+
+ private columnHeadersView!: ColumnHeadersView;
+
+ private rowsView!: RowsView;
public visibilityChanged?: Callback;
+ private messageStore?: ArrayStore;
+
+ public init(): void {
+ this.columnHeadersView = this.getView('columnHeadersView');
+ this.rowsView = this.getView('rowsView');
+
+ this.messageStore = new ArrayStore({
+ key: 'id',
+ });
+ }
+
private getAIChatConfig(): AIChatOptions {
+ const popupOptions = this.getAIChatPopupOptions();
+ const chatOptions = this.getAIChatOptions();
+
return {
container: this.element(),
createComponent: this._createComponent.bind(this),
- onVisibilityChanged: (visible: boolean): void => {
- this.visibilityChanged?.fire(visible);
+ onChatCleared: (): void => {},
+ popupOptions,
+ chatOptions,
+ };
+ }
+
+ private getPopupHeight(): number {
+ const headersHeight = this.columnHeadersView.getHeight();
+ const rowsViewHeight = getHeight(this.rowsView.element());
+
+ return headersHeight + rowsViewHeight - AI_ASSISTANT_POPUP_OFFSET * 2;
+ }
+
+ private getAIChatPopupOptions(): PopupProperties {
+ const position: PositionConfig = {
+ my: 'right top',
+ at: 'right top',
+ of: this.columnHeadersView.element(),
+ collision: 'fit',
+ offset: `${-AI_ASSISTANT_POPUP_OFFSET} ${AI_ASSISTANT_POPUP_OFFSET}`,
+ boundaryOffset: `${AI_ASSISTANT_POPUP_OFFSET} ${AI_ASSISTANT_POPUP_OFFSET}`,
+ };
+
+ // @ts-ignore
+ return {
+ title: this.option('aiAssistant.title') ?? '',
+ position,
+ // NOTE: DevExtreme Popup supports function-valued height at runtime
+ // (re-evaluated automatically on show and window resize).
+ // @ts-expect-error type declaration
+ height: () => this.getPopupHeight(),
+ onShowing: (): void => {
+ this.visibilityChanged?.fire(true);
},
+ onHidden: (): void => {
+ this.visibilityChanged?.fire(false);
+ },
+ ...this.option('aiAssistant.popup'),
};
}
- protected _renderCore(): void {
- const config = this.getAIChatConfig();
+ private getAIChatOptions(): ChatProperties {
+ return {
+ dataSource: this.messageStore,
+ onMessageEntered: (e): void => {
+ const parsedTimestamp = isString(e.message.timestamp)
+ ? Date.parse(e.message.timestamp)
+ : e.message.timestamp?.toString() ?? '';
+
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ this.messageStore?.insert({
+ ...e.message,
+ id: `${e.message.author?.id}-${parsedTimestamp}`,
+ });
+ },
+ ...this.option('aiAssistant.chat'),
+ };
+ }
+ protected _renderCore(): void {
if (!this.aiChatInstance) {
+ const config = this.getAIChatConfig();
+
this.aiChatInstance = new AIChat(config);
}
}
+ public optionChanged(args: OptionChanged): void {
+ if (args.name === 'aiAssistant') {
+ const enabledChanged = isEnabledOption(args.fullName, args.value);
+
+ if (enabledChanged) {
+ if (this.isVisible()) {
+ this?._invalidate();
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ this?.hide();
+ }
+ }
+
+ const popupOptionsChanged = isTitleOption(args.fullName, args.value)
+ || isPopupOptions(args.fullName, args.value);
+ const chatOptionsChanged = isChatOptions(args.fullName, args.value);
+
+ if (popupOptionsChanged || chatOptionsChanged) {
+ this.aiChatInstance?.updateOptions(
+ this.getAIChatConfig(),
+ popupOptionsChanged,
+ chatOptionsChanged,
+ );
+ }
+
+ args.handled = true;
+ } else {
+ super.optionChanged(args);
+ }
+ }
+
protected callbackNames(): string[] {
return ['visibilityChanged'];
}
diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view_controller.ts
index 140875214e51..301255ea91c8 100644
--- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view_controller.ts
+++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view_controller.ts
@@ -11,6 +11,7 @@ import type { ToolbarItem } from '@ts/grids/new/grid_core/toolbar/types';
import { ViewController } from '../m_modules';
import type { AIAssistantView } from './ai_assistant_view';
+import { isEnabledOption, isTitleOption } from './utils';
export class AIAssistantViewController extends ViewController {
private aiAssistantView?: AIAssistantView;
@@ -27,23 +28,31 @@ export class AIAssistantViewController extends ViewController {
this.aiAssistantView = this.getView('aiAssistantView');
this.headerPanel = this.getView('headerPanel');
- this.aiAssistantView.visibilityChanged?.add((visible: boolean): void => {
- this.getAiAssistantButton()?.toggleClass(ACTIVE_STATE_CLASS, visible);
- });
-
- const isAiAssistantEnabled = this.option('aiAssistant.enabled'); // TODO clarify option name
+ const isAiAssistantEnabled = this.option('aiAssistant.enabled');
if (isAiAssistantEnabled) {
const aiAssistantToolbarItem = this.createAiAssistantToolbarItem();
this.headerPanel?.registerToolbarItem(AI_ASSISTANT_BUTTON_NAME, aiAssistantToolbarItem);
}
+
+ const visibilityChangedHandler = (visible: boolean): void => {
+ this.getAiAssistantButton()?.toggleClass(ACTIVE_STATE_CLASS, visible);
+ };
+
+ this.aiAssistantView?.visibilityChanged?.remove(visibilityChangedHandler);
+ this.aiAssistantView.visibilityChanged?.add(visibilityChangedHandler);
}
public optionChanged(args: OptionChanged): void {
if (args.name === 'aiAssistant') {
- this.syncAiAssistantItem();
- args.handled = true;
+ const enabledChanged = isEnabledOption(args.fullName, args.value);
+ const titleChanged = isTitleOption(args.fullName, args.value);
+
+ if (enabledChanged || titleChanged) {
+ this.syncAiAssistantItem();
+ args.handled = true;
+ }
} else {
super.optionChanged(args);
}
@@ -54,28 +63,28 @@ export class AIAssistantViewController extends ViewController {
}
private syncAiAssistantItem(): void {
- const isAiAssistantEnabled = this.option('aiAssistant.enabled'); // TODO clarify option name
+ const isAiAssistantEnabled = this.option('aiAssistant.enabled');
if (isAiAssistantEnabled) {
const aiAssistantToolbarItem = this.createAiAssistantToolbarItem();
this.headerPanel?.applyToolbarItem(AI_ASSISTANT_BUTTON_NAME, aiAssistantToolbarItem);
- this.aiAssistantView?._invalidate();
} else {
this.headerPanel?.removeToolbarItem(AI_ASSISTANT_BUTTON_NAME);
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- this.aiAssistantView?.hide();
}
}
private createAiAssistantToolbarItem(): ToolbarItem {
const onClickHandler = (): Promise => this.toggle();
- const hintText = this.option('aiAssistant.title'); // TODO clarify option name
+ const hintText = this.option('aiAssistant.title');
+
+ const isActive = this.aiAssistantView?.isShown();
const aiAssistantToolbarItemClass = this.headerPanel?.getToolbarButtonClass(
this.addWidgetPrefix(CLASSES.aiAssistantButton),
);
+ const aiAssistantToolbarItemStateClass = isActive ? ACTIVE_STATE_CLASS : '';
return {
widget: 'dxButton',
@@ -87,7 +96,7 @@ export class AIAssistantViewController extends ViewController {
text: hintText,
elementAttr: {
'aria-haspopup': 'dialog',
- class: aiAssistantToolbarItemClass,
+ class: `${aiAssistantToolbarItemClass} ${aiAssistantToolbarItemStateClass}`,
},
},
showText: 'inMenu',
diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/const.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/const.ts
index fccda9cb19ef..4e52bee28f6c 100644
--- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/const.ts
+++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/const.ts
@@ -4,3 +4,5 @@ export const AI_ASSISTANT_ICON_NAME = 'chatsparkleoutline';
export const CLASSES = {
aiAssistantButton: 'ai-assistant-button',
};
+
+export const AI_ASSISTANT_POPUP_OFFSET = 12;
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
new file mode 100644
index 000000000000..b007173298ac
--- /dev/null
+++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/utils.ts
@@ -0,0 +1,13 @@
+import { isObject } from '@js/core/utils/type';
+
+export const isEnabledOption = (optionName: string, value: unknown): boolean => optionName.startsWith('aiAssistant.enabled')
+ || (optionName === 'aiAssistant' && isObject(value) && 'enabled' in value);
+
+export const isTitleOption = (optionName: string, value: unknown): boolean => optionName.startsWith('aiAssistant.title')
+ || (optionName === 'aiAssistant' && isObject(value) && 'title' in value);
+
+export const isPopupOptions = (optionName: string, value: unknown): boolean => optionName.startsWith('aiAssistant.popup')
+ || (optionName === 'aiAssistant' && isObject(value) && 'popup' in value);
+
+export const isChatOptions = (optionName: string, value: unknown): boolean => optionName.startsWith('aiAssistant.chat')
+ || (optionName === 'aiAssistant' && isObject(value) && 'chat' 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 037428577025..aee8541d3881 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
@@ -12,7 +12,7 @@ import Chat from '@js/ui/chat';
import Popup from '@ts/ui/popup/m_popup';
import { AIChat } from './ai_chat';
-import { CLASSES, DEFAULT_POPUP_OPTIONS } from './const';
+import { CLASSES, CLEAR_CHAT_ICON, DEFAULT_POPUP_OPTIONS } from './const';
import type { AIChatOptions } from './types';
const mockPopupInstance = {
@@ -21,7 +21,9 @@ const mockPopupInstance = {
option: jest.fn<(name: string) => unknown>().mockReturnValue(false),
};
-const mockChatInstance = {};
+const mockChatInstance = {
+ option: jest.fn(),
+};
const createComponentMock = jest.fn((
_el: any,
@@ -127,7 +129,7 @@ describe('AIChat', () => {
});
});
- describe('onVisibilityChanged', () => {
+ describe('clearChatButton', () => {
const getPopupConfig = (): any => {
const call = createComponentMock.mock.calls.find(
([, Widget]) => Widget === Popup,
@@ -138,35 +140,107 @@ describe('AIChat', () => {
return (call as any)[2];
};
- it('should call onVisibilityChanged with true on showing', () => {
- const onVisibilityChanged = jest.fn();
- createAIChat({ onVisibilityChanged });
+ it('should include toolbarItems with clear chat button when onChatCleared is provided', () => {
+ const onChatCleared = jest.fn();
+ createAIChat({ onChatCleared });
const popupConfig = getPopupConfig();
- popupConfig.onShowing();
- expect(onVisibilityChanged).toHaveBeenCalledTimes(1);
- expect(onVisibilityChanged).toHaveBeenCalledWith(true);
+ expect(popupConfig.toolbarItems).toEqual([
+ expect.objectContaining({
+ widget: 'dxButton',
+ toolbar: 'top',
+ location: 'after',
+ options: expect.objectContaining({
+ icon: CLEAR_CHAT_ICON,
+ onClick: onChatCleared,
+ }),
+ }),
+ ]);
});
- it('should call onVisibilityChanged with false on hidden', () => {
- const onVisibilityChanged = jest.fn();
- createAIChat({ onVisibilityChanged });
+ it('should not include toolbarItems when onChatCleared is not provided', () => {
+ createAIChat();
const popupConfig = getPopupConfig();
- popupConfig.onHidden();
- expect(onVisibilityChanged).toHaveBeenCalledTimes(1);
- expect(onVisibilityChanged).toHaveBeenCalledWith(false);
+ expect(popupConfig.toolbarItems).toBeUndefined();
});
+ });
- it('should not throw when onVisibilityChanged is not provided', () => {
- createAIChat();
+ describe('updateOptions', () => {
+ const triggerContentTemplate = (): void => {
+ const call = createComponentMock.mock.calls.find(
+ ([, Widget]) => Widget === Popup,
+ );
+ const popupConfig = (call as any)[2];
- const popupConfig = getPopupConfig();
+ popupConfig.contentTemplate($(''));
+ };
+
+ it('should call popupInstance.option with new popupOptions when updatePopup is true', () => {
+ const { aiChat, $container } = createAIChat();
+ const newPopupOptions = { title: 'Updated' };
+
+ aiChat.updateOptions({
+ container: $container,
+ createComponent: createComponentMock as any,
+ popupOptions: newPopupOptions,
+ }, true, false);
+
+ expect(mockPopupInstance.option).toHaveBeenCalledWith(newPopupOptions);
+ });
+
+ it('should not call popupInstance.option when updatePopup is false', () => {
+ const { aiChat, $container } = createAIChat();
+
+ aiChat.updateOptions({
+ container: $container,
+ createComponent: createComponentMock as any,
+ popupOptions: { title: 'Updated' },
+ }, false, false);
+
+ expect(mockPopupInstance.option).not.toHaveBeenCalledWith({ title: 'Updated' });
+ });
+
+ it('should call chatInstance.option with new chatOptions when updateChat is true', () => {
+ const { aiChat, $container } = createAIChat();
+ triggerContentTemplate();
+
+ const newChatOptions = { showAvatar: true };
+
+ aiChat.updateOptions({
+ container: $container,
+ createComponent: createComponentMock as any,
+ chatOptions: newChatOptions,
+ }, false, true);
+
+ expect(mockChatInstance.option).toHaveBeenCalledWith(newChatOptions);
+ });
+
+ it('should not call chatInstance.option when updateChat is false', () => {
+ const { aiChat, $container } = createAIChat();
+ triggerContentTemplate();
+
+ aiChat.updateOptions({
+ container: $container,
+ createComponent: createComponentMock as any,
+ chatOptions: { showAvatar: true },
+ }, false, false);
+
+ expect(mockChatInstance.option).not.toHaveBeenCalled();
+ });
+
+ it('should not throw when chatInstance is not created and updateChat is true', () => {
+ const { aiChat, $container } = createAIChat();
- expect(() => { popupConfig.onShowing(); }).not.toThrow();
- expect(() => { popupConfig.onHidden(); }).not.toThrow();
+ expect(() => {
+ aiChat.updateOptions({
+ container: $container,
+ createComponent: createComponentMock as any,
+ chatOptions: { showAvatar: true },
+ }, false, true);
+ }).not.toThrow();
});
});
});
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 4ff918143c3a..687a2f72fff2 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
@@ -1,19 +1,29 @@
+import messageLocalization from '@js/common/core/localization/message';
import $ from '@js/core/renderer';
import type { Properties as ChatProperties } from '@js/ui/chat';
import Chat from '@js/ui/chat';
-import type { Properties as PopupProperties } from '@js/ui/popup';
+import type { Properties as PopupProperties, ToolbarItem } from '@js/ui/popup';
+import {
+ CHAT_MESSAGELIST_EMPTY_IMAGE_CLASS,
+ CHAT_MESSAGELIST_EMPTY_MESSAGE_CLASS,
+ CHAT_MESSAGELIST_EMPTY_PROMPT_CLASS,
+} from '@ts/ui/chat/messagelist';
import Popup from '@ts/ui/popup/m_popup';
-import { CLASSES, DEFAULT_POPUP_OPTIONS } from './const';
+import {
+ CLASSES, CLEAR_CHAT_ICON,
+ DEFAULT_CHAT_OPTIONS,
+ DEFAULT_POPUP_OPTIONS,
+} from './const';
import type { AIChatOptions } from './types';
export class AIChat {
private readonly popupInstance: Popup;
- private chatInstance!: Chat;
+ private chatInstance?: Chat;
constructor(
- private readonly options: AIChatOptions,
+ private options: AIChatOptions,
) {
const { container, createComponent } = options;
@@ -22,19 +32,34 @@ export class AIChat {
}
private getChatConfig(): ChatProperties {
- return {};
+ return {
+ ...DEFAULT_CHAT_OPTIONS,
+ emptyViewTemplate: (_data, container): void => {
+ const $image = $('
')
+ .addClass(CHAT_MESSAGELIST_EMPTY_IMAGE_CLASS);
+ const $message = $('
')
+ .addClass(CHAT_MESSAGELIST_EMPTY_MESSAGE_CLASS)
+ .text(messageLocalization.format('dxDataGrid-aiAChatEmptyViewMessage'));
+ const $prompt = $('
')
+ .addClass(CHAT_MESSAGELIST_EMPTY_PROMPT_CLASS)
+ .text(messageLocalization.format('dxDataGrid-aiChatEmptyViewPrompt'));
+
+ $(container)
+ .append($image)
+ .append($message)
+ .append($prompt);
+ },
+ ...this.options.chatOptions,
+ };
}
private getPopupConfig(): PopupProperties {
+ const clearChatButton = this.getClearChatButton();
+
return {
...DEFAULT_POPUP_OPTIONS,
wrapperAttr: { class: `${CLASSES.aiChat} ${CLASSES.aiDialog}` },
- onShowing: (): void => {
- this.options.onVisibilityChanged?.(true);
- },
- onHidden: (): void => {
- this.options.onVisibilityChanged?.(false);
- },
+ toolbarItems: clearChatButton ? [clearChatButton] : undefined,
contentTemplate: ($container): void => {
const $editorContainer = $('
')
.addClass(CLASSES.aiChatContent)
@@ -46,9 +71,41 @@ export class AIChat {
this.getChatConfig(),
);
},
+ ...this.options.popupOptions,
+ };
+ }
+
+ private getClearChatButton(): ToolbarItem | undefined {
+ const { onChatCleared } = this.options;
+
+ if (!onChatCleared) {
+ return undefined;
+ }
+
+ return {
+ widget: 'dxButton',
+ toolbar: 'top',
+ location: 'after',
+ options: {
+ icon: CLEAR_CHAT_ICON,
+ hint: messageLocalization.format('dxDataGrid-aiAssistantClearButtonText'),
+ onClick: onChatCleared,
+ },
};
}
+ public updateOptions(options: AIChatOptions, updatePopup: boolean, updateChat: boolean): void {
+ this.options = options;
+
+ if (updatePopup) {
+ this.popupInstance.option(this.options.popupOptions);
+ }
+
+ if (updateChat && this.options.chatOptions) {
+ this.chatInstance?.option(this.options.chatOptions);
+ }
+ }
+
public toggle(): Promise
{
return this.popupInstance.toggle();
}
@@ -58,6 +115,6 @@ export class AIChat {
}
public isShown(): boolean {
- return !!this.popupInstance.option('visible');
+ return !!this.popupInstance?.option('visible');
}
}
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 f41d51efda7d..549942cb3154 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,13 +1,24 @@
export const DEFAULT_POPUP_OPTIONS = {
- width: 360,
- height: 'auto',
+ width: 400,
+ minWidth: 400,
+ minHeight: 480,
visible: false,
shading: false,
showCloseButton: true,
};
+export const DEFAULT_CHAT_OPTIONS = {
+ showAvatar: false,
+ showDayHeaders: false,
+ showMessageTimestamp: false,
+ showUserAvatar: false,
+ speechToTextEnabled: true,
+};
+
export const CLASSES = {
aiChat: 'dx-ai-chat',
aiDialog: 'dx-aidialog',
aiChatContent: 'dx-ai-chat__content',
};
+
+export const CLEAR_CHAT_ICON = 'chatdismiss';
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 eb30cb8b0f93..ed3c8bb6aa06 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
@@ -8,10 +8,8 @@ export interface AIChatOptions {
container: dxElementWrapper;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createComponent: CreateComponent;
- onMessageEntered?: () => void;
- onChatCleared?: () => void;
- onRegenerate?: () => void;
- onVisibilityChanged?: (visible: boolean) => void;
popupOptions?: PopupProperties;
chatOptions?: ChatProperties;
+ onChatCleared?: () => void;
+ onRegenerate?: () => void;
}
diff --git a/packages/devextreme/js/__internal/grids/grid_core/m_types.ts b/packages/devextreme/js/__internal/grids/grid_core/m_types.ts
index 9eb4dc60f88f..df1815b557bb 100644
--- a/packages/devextreme/js/__internal/grids/grid_core/m_types.ts
+++ b/packages/devextreme/js/__internal/grids/grid_core/m_types.ts
@@ -4,7 +4,9 @@ import type { GridBase, GridBaseOptions, SelectionBase } from '@js/common/grids'
import type { Component } from '@js/core/component';
import type { PropertyType } from '@js/core/index';
import type { dxElementWrapper } from '@js/core/renderer';
+import type { Properties as ChatOptions } from '@js/ui/chat';
import type { Properties as DataGridOptions } from '@js/ui/data_grid';
+import type { Properties as PopupOptions } from '@js/ui/popup';
import type { Properties as TreeListdOptions } from '@js/ui/tree_list';
import type Widget from '@js/ui/widget/ui.widget';
@@ -132,6 +134,8 @@ export interface InternalGridOptions extends GridBaseOptions