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 269dea6238aa..a8514389639c 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 @@ -55,14 +55,30 @@ const createAIAssistantView = ({ return undefined; }); + const $columnHeadersElement = $('
').appendTo($container); + const $rowsViewElement = $('
').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