diff --git a/packages/devextreme/js/__internal/grids/data_grid/module_not_extended/ai_assistant.ts b/packages/devextreme/js/__internal/grids/data_grid/module_not_extended/ai_assistant.ts index d17168f3e673..023e3f36b4dd 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/module_not_extended/ai_assistant.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/module_not_extended/ai_assistant.ts @@ -1,9 +1,17 @@ -import { AIAssistantView } from '@ts/grids/grid_core/ai_assistant/m_ai_assistant_view'; -import { AIAssistantViewController } from '@ts/grids/grid_core/ai_assistant/m_ai_assistant_view_controller'; +import { AIAssistantView } from '@ts/grids/grid_core/ai_assistant/ai_assistant_view'; +import { AIAssistantViewController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_view_controller'; import gridCore from '../m_core'; gridCore.registerModule('aiAssistant', { + defaultOptions() { + return { + aiAssistant: { + enabled: false, + title: 'AI Assistant', // TODO add localization message + }, + }; + }, controllers: { aiAssistant: AIAssistantViewController, }, diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_view.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view.test.ts similarity index 71% rename from packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_view.test.ts rename to packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view.test.ts index 1ea0905daaaf..3499d766fe88 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_view.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view.test.ts @@ -12,11 +12,12 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import wrapInstanceWithMocks from '@ts/grids/grid_core/__tests__/__mock__/helpers/wrapInstance'; -import { AIChat } from '../ai_chat/ai_chat'; -import { AIAssistantView } from './m_ai_assistant_view'; +import { AIChat } from '../../ai_chat/ai_chat'; +import type { AIChatOptions } from '../../ai_chat/types'; +import { AIAssistantView } from '../ai_assistant_view'; -jest.mock('../ai_chat/ai_chat', (): any => { - const original = jest.requireActual('../ai_chat/ai_chat'); +jest.mock('../../ai_chat/ai_chat', (): any => { + const original = jest.requireActual('../../ai_chat/ai_chat'); return { ...original, @@ -152,16 +153,22 @@ describe('AIAssistantView', () => { }); }); - describe('show', () => { - it('should delegate to AIChat show method', async () => { + describe('toggle', () => { + it('should delegate to AIChat toggle method', async () => { const { aiAssistantView } = createAIAssistantView(); - await aiAssistantView.show(); + await aiAssistantView.toggle(); const aiChatInstance = (AIChat as jest.Mock) - .mock.results[0].value as { show: jest.Mock; hide: jest.Mock }; + .mock.results[0].value as { toggle: jest.Mock }; - expect(aiChatInstance.show).toHaveBeenCalledTimes(1); + expect(aiChatInstance.toggle).toHaveBeenCalledTimes(1); + }); + + it('should return resolved false promise when aiChatInstance is not created', () => { + const { aiAssistantView } = createAIAssistantView({ render: false }); + + return expect(aiAssistantView.toggle()).resolves.toBe(false); }); }); @@ -172,25 +179,54 @@ describe('AIAssistantView', () => { await aiAssistantView.hide(); const aiChatInstance = (AIChat as jest.Mock) - .mock.results[0].value as { show: jest.Mock; hide: jest.Mock }; + .mock.results[0].value as { hide: jest.Mock }; expect(aiChatInstance.hide).toHaveBeenCalledTimes(1); }); - }); - describe('show when not initialized', () => { it('should return resolved false promise when aiChatInstance is not created', () => { const { aiAssistantView } = createAIAssistantView({ render: false }); - return expect(aiAssistantView.show()).resolves.toBe(false); + return expect(aiAssistantView.hide()).resolves.toBe(false); }); }); - describe('hide when not initialized', () => { - it('should return resolved false promise when aiChatInstance is not created', () => { + describe('isShown', () => { + it('should delegate to AIChat isShown method', () => { + const { aiAssistantView } = createAIAssistantView(); + + const aiChatInstance = (AIChat as jest.Mock) + .mock.results[0].value as { isShown: jest.Mock }; + + aiChatInstance.isShown.mockReturnValue(true); + expect(aiAssistantView.isShown()).toBe(true); + + aiChatInstance.isShown.mockReturnValue(false); + expect(aiAssistantView.isShown()).toBe(false); + }); + + it('should return false when aiChatInstance is not created', () => { const { aiAssistantView } = createAIAssistantView({ render: false }); - return expect(aiAssistantView.hide()).resolves.toBe(false); + expect(aiAssistantView.isShown()).toBe(false); + }); + }); + + describe('onVisibilityChanged', () => { + it('should fire onVisibilityChanged callback when popup visibility changes', () => { + const { aiAssistantView } = createAIAssistantView(); + const callback = jest.fn(); + + aiAssistantView.onVisibilityChanged = callback; + + const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions; + aiChatConfig.onVisibilityChanged?.(true); + + expect(callback).toHaveBeenCalledWith(true); + + aiChatConfig.onVisibilityChanged?.(false); + + expect(callback).toHaveBeenCalledWith(false); }); }); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view_controller.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view_controller.integration.test.ts new file mode 100644 index 000000000000..8507eb20f77b --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view_controller.integration.test.ts @@ -0,0 +1,146 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import type { Properties as DataGridProperties } from '@js/ui/data_grid'; + +import { + afterTest, + beforeTest, + createDataGrid as commonCreateDataGrid, + type DataGridInstance, +} from '../../__tests__/__mock__/helpers/utils'; + +// TODO remove when types added to public dts +interface AIAssistantDataGridProperties extends DataGridProperties { + aiAssistant?: { + enabled?: boolean; + title?: string; + }; +} + +// TODO remove when types added to public dts +const createDataGrid = ( + options: AIAssistantDataGridProperties = {}, +// eslint-disable-next-line @typescript-eslint/no-explicit-any +): ReturnType => commonCreateDataGrid(options as any); + +const AI_ASSISTANT_BUTTON_SELECTOR = '.dx-datagrid-ai-assistant-button'; +const HIDDEN_CLASS = 'dx-hidden'; + +const getAiAssistantButton = ( + instance: DataGridInstance, +): Element | null => instance + .element() + .querySelector(AI_ASSISTANT_BUTTON_SELECTOR); + +const isAiAssistantButtonVisible = (instance: DataGridInstance): boolean => { + const button = getAiAssistantButton(instance); + + if (!button) { + return false; + } + + return !button.closest(`.${HIDDEN_CLASS}`); +}; + +describe('AIAssistantViewController', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + describe('init', () => { + it('should register toolbar button when aiAssistant.enabled is true', async () => { + const { instance } = await createDataGrid({ + dataSource: [{ id: 1 }], + aiAssistant: { enabled: true, title: 'AI Assistant' }, + }); + + const button = getAiAssistantButton(instance); + + expect(button).not.toBeNull(); + }); + + it('should not register toolbar button when aiAssistant.enabled is false', async () => { + const { instance } = await createDataGrid({ + dataSource: [{ id: 1 }], + aiAssistant: { enabled: false }, + }); + + const button = getAiAssistantButton(instance); + + expect(button).toBeNull(); + }); + + it('should not register toolbar button when aiAssistant is not set', async () => { + const { instance } = await createDataGrid({ + dataSource: [{ id: 1 }], + }); + + const button = getAiAssistantButton(instance); + + expect(button).toBeNull(); + }); + }); + + describe('optionChanged', () => { + it('should add toolbar button when aiAssistant.enabled changes to true', async () => { + const { instance } = await createDataGrid({ + dataSource: [{ id: 1 }], + aiAssistant: { enabled: false, title: 'AI Assistant' }, + }); + + instance.option('aiAssistant.enabled', true); + jest.runAllTimers(); + await Promise.resolve(); + jest.runAllTimers(); + + const button = getAiAssistantButton(instance); + + expect(button).not.toBeNull(); + }); + + it('should hide ai assistant button when aiAssistant.enabled changes to false', async () => { + const { instance } = await createDataGrid({ + dataSource: [{ id: 1 }], + aiAssistant: { enabled: true, title: 'AI Assistant' }, + }); + + expect(isAiAssistantButtonVisible(instance)).toBe(true); + + instance.option('aiAssistant.enabled', false); + jest.runAllTimers(); + await Promise.resolve(); + jest.runAllTimers(); + + expect(isAiAssistantButtonVisible(instance)).toBe(false); + }); + }); + + describe('toolbar button', () => { + it('should have aria-haspopup dialog attribute', async () => { + const { instance } = await createDataGrid({ + dataSource: [{ id: 1 }], + aiAssistant: { enabled: true, title: 'AI Assistant' }, + }); + + const button = getAiAssistantButton(instance); + + expect(button?.getAttribute('aria-haspopup')).toBe('dialog'); + }); + + it('should have correct hint text from aiAssistant.title', async () => { + const { instance } = await createDataGrid({ + dataSource: [{ id: 1 }], + aiAssistant: { enabled: true, title: 'My Custom Title' }, + }); + + const button = getAiAssistantButton(instance); + + expect(button?.getAttribute('title')).toBe('My Custom Title'); + }); + }); +}); 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 new file mode 100644 index 000000000000..43f672d9c942 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view_controller.test.ts @@ -0,0 +1,173 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; + +import { AIAssistantViewController } from '../ai_assistant_view_controller'; + +interface MockAIAssistantView { + toggle: jest.Mock<() => Promise>; + hide: jest.Mock<() => Promise>; + _invalidate: jest.Mock; + onVisibilityChanged?: (visible: boolean) => void; +} + +interface MockHeaderPanel { + registerToolbarItem: jest.Mock; + applyToolbarItem: jest.Mock; + removeToolbarItem: jest.Mock; +} + +const createMockAIAssistantView = (): MockAIAssistantView => ({ + toggle: jest.fn<() => Promise>().mockResolvedValue(true), + hide: jest.fn<() => Promise>().mockResolvedValue(true), + _invalidate: jest.fn(), +}); + +const createMockHeaderPanel = (): MockHeaderPanel => ({ + registerToolbarItem: jest.fn(), + applyToolbarItem: jest.fn(), + removeToolbarItem: jest.fn(), +}); + +const createAIAssistantViewController = ( + options: Record = {}, +): { + controller: AIAssistantViewController; + mockView: MockAIAssistantView; + mockHeaderPanel: MockHeaderPanel; +} => { + const mockView = createMockAIAssistantView(); + const mockHeaderPanel = createMockHeaderPanel(); + + const mockComponent = { + NAME: 'dxDataGrid', + _views: { + aiAssistantView: mockView, + headerPanel: mockHeaderPanel, + }, + _controllers: {}, + option(name?: string) { + if (name !== undefined) { + return options[name]; + } + return options; + }, + }; + + const controller = new AIAssistantViewController(mockComponent); + controller.init(); + + return { controller, mockView, mockHeaderPanel }; +}; + +describe('AIAssistantViewController', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('init', () => { + it('should get aiAssistantView reference', () => { + const { controller } = createAIAssistantViewController(); + + expect(controller).toBeDefined(); + }); + }); + + describe('toggle', () => { + it('should delegate to aiAssistantView toggle', async () => { + const { controller, mockView } = createAIAssistantViewController({ + 'aiAssistant.enabled': true, + }); + + await controller.toggle(); + + expect(mockView.toggle).toHaveBeenCalledTimes(1); + }); + }); + + describe('onVisibilityChanged subscription', () => { + it('should subscribe to aiAssistantView.onVisibilityChanged on init', () => { + const { mockView } = createAIAssistantViewController(); + + expect(mockView.onVisibilityChanged).toBeDefined(); + expect(typeof mockView.onVisibilityChanged).toBe('function'); + }); + }); + + describe('optionChanged', () => { + it('should set handled to true for aiAssistant options', () => { + const { controller } = createAIAssistantViewController(); + + const args = { + name: 'aiAssistant' as const, + fullName: 'aiAssistant.enabled' as const, + value: true, + previousValue: false, + handled: false, + }; + + controller.optionChanged(args); + + expect(args.handled).toBe(true); + }); + + it('should hide aiAssistantView when aiAssistant.enabled changes to false', () => { + const options: Record = { 'aiAssistant.enabled': true }; + const { controller, mockView } = createAIAssistantViewController(options); + + options['aiAssistant.enabled'] = false; + + controller.optionChanged({ + name: 'aiAssistant' as const, + fullName: 'aiAssistant.enabled' as const, + value: false, + previousValue: true, + handled: false, + }); + + expect(mockView.hide).toHaveBeenCalledTimes(1); + }); + + it('should invalidate aiAssistantView when enabling', () => { + const options: Record = { 'aiAssistant.enabled': false }; + const { controller, mockView } = createAIAssistantViewController(options); + + options['aiAssistant.enabled'] = true; + + controller.optionChanged({ + name: 'aiAssistant' as const, + fullName: 'aiAssistant.enabled' as const, + value: true, + previousValue: false, + handled: false, + }); + + expect(mockView._invalidate).toHaveBeenCalledTimes(1); + }); + + it('should not invalidate aiAssistantView when disabling', () => { + const options: Record = { 'aiAssistant.enabled': true }; + const { controller, mockView } = createAIAssistantViewController(options); + + options['aiAssistant.enabled'] = false; + + controller.optionChanged({ + name: 'aiAssistant' as const, + fullName: 'aiAssistant.enabled' as const, + value: false, + previousValue: true, + handled: false, + }); + + expect(mockView._invalidate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_view.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view.ts similarity index 62% rename from packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_view.ts rename to packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view.ts index 4e19e58f0425..13d5cf0cc5a0 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_view.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view.ts @@ -5,10 +5,15 @@ import { View } from '../m_modules'; export class AIAssistantView extends View { private aiChatInstance!: AIChat; + public onVisibilityChanged?: (visible: boolean) => void; + private getAIChatConfig(): AIChatOptions { return { container: this.element(), createComponent: this._createComponent.bind(this), + onVisibilityChanged: (visible: boolean): void => { + this.onVisibilityChanged?.(visible); + }, }; } @@ -21,14 +26,18 @@ export class AIAssistantView extends View { } public isVisible(): boolean { - return this.option('aiAssistant.enabled'); + return !!this.option('aiAssistant.enabled'); } - public show(): Promise { - return this.aiChatInstance?.show() ?? Promise.resolve(false); + public isShown(): boolean { + return this.aiChatInstance?.isShown() ?? false; } public hide(): Promise { return this.aiChatInstance?.hide() ?? Promise.resolve(false); } + + public toggle(): Promise { + return this.aiChatInstance?.toggle() ?? Promise.resolve(false); + } } 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 new file mode 100644 index 000000000000..de334082a84b --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view_controller.ts @@ -0,0 +1,99 @@ +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import type { InitializedEvent as ButtonInitializedEvent } from '@js/ui/button'; +import { ACTIVE_STATE_CLASS } from '@ts/core/widget/widget'; +import type { HeaderPanel } from '@ts/grids/grid_core/header_panel/m_header_panel'; +import type { OptionChanged } from '@ts/grids/grid_core/m_types'; +import type { ToolbarItem } from '@ts/grids/new/grid_core/toolbar/types'; + +import { ViewController } from '../m_modules'; +import type { AIAssistantView } from './ai_assistant_view'; + +const AI_ASSISTANT_BUTTON_NAME = 'aiAssistantButton'; +const AI_ASSISTANT_BUTTON_CLASS = 'ai-assistant-button'; +const AI_ASSISTANT_ICON_NAME = 'chatsparkleoutline'; + +export class AIAssistantViewController extends ViewController { + private aiAssistantView!: AIAssistantView; + + private headerPanel?: HeaderPanel; + + private $aiAssistantButton?: dxElementWrapper; + + public init(): void { + this.aiAssistantView = this.getView('aiAssistantView'); + this.headerPanel = this.getView('headerPanel'); + + this.aiAssistantView.onVisibilityChanged = (visible: boolean): void => { + this.$aiAssistantButton?.toggleClass(ACTIVE_STATE_CLASS, visible); + }; + + const isAiAssistantEnabled = this.option('aiAssistant.enabled'); // TODO clarify option name + + if (isAiAssistantEnabled) { + const aiAssistantToolbarItem = this.createAiAssistantToolbarItem(); + + this.headerPanel?.registerToolbarItem(AI_ASSISTANT_BUTTON_NAME, aiAssistantToolbarItem); + } + } + + public optionChanged(args: OptionChanged): void { + if (args.name === 'aiAssistant') { + this.syncAiAssistantItem(); + args.handled = true; + } else { + super.optionChanged(args); + } + } + + public toggle(): Promise { + return this.aiAssistantView.toggle(); + } + + private syncAiAssistantItem(): void { + const isAiAssistantEnabled = this.option('aiAssistant.enabled'); // TODO clarify option name + + 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 onInitialized = (e: ButtonInitializedEvent): void => { + this.$aiAssistantButton = $(e.element); + + if (this.headerPanel) { + const aiAssistantClass = this.addWidgetPrefix(AI_ASSISTANT_BUTTON_CLASS); + this.$aiAssistantButton.addClass(this.headerPanel.getToolbarButtonClass(aiAssistantClass)); + } + }; + const hintText = this.option('aiAssistant.title'); // TODO clarify option name + + return { + widget: 'dxButton', + options: { + icon: AI_ASSISTANT_ICON_NAME, + activeStateEnabled: false, + onClick: onClickHandler, + hint: hintText, + text: hintText, + onInitialized, + elementAttr: { 'aria-haspopup': 'dialog' }, + }, + showText: 'inMenu', + location: 'after', + name: AI_ASSISTANT_BUTTON_NAME, + locateInMenu: 'auto', + sortIndex: 5, + }; + } +} diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_view_controller.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_view_controller.test.ts deleted file mode 100644 index 1dcb7d198766..000000000000 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_view_controller.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { - afterEach, - beforeEach, - describe, - expect, - it, - jest, -} from '@jest/globals'; - -import { AIAssistantViewController } from './m_ai_assistant_view_controller'; - -interface MockAIAssistantView { - show: jest.Mock<() => Promise>; - hide: jest.Mock<() => Promise>; -} - -const createMockAIAssistantView = (): MockAIAssistantView => ({ - show: jest.fn<() => Promise>().mockResolvedValue(true), - hide: jest.fn<() => Promise>().mockResolvedValue(true), -}); - -const createAIAssistantViewController = (): { - controller: AIAssistantViewController; - mockView: ReturnType; -} => { - const mockView = createMockAIAssistantView(); - const mockComponent = { - _views: { - aiAssistantView: mockView, - }, - _controllers: {}, - }; - - const controller = new AIAssistantViewController(mockComponent); - controller.init(); - - return { controller, mockView }; -}; - -const beforeTest = (): void => { - jest.clearAllMocks(); -}; - -describe('AIAssistantViewController', () => { - beforeEach(beforeTest); - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('init', () => { - it('should get aiAssistantView reference', () => { - const { controller } = createAIAssistantViewController(); - - expect(controller).toBeDefined(); - }); - }); - - describe('show', () => { - it('should delegate to aiAssistantView show method', async () => { - const { controller, mockView } = createAIAssistantViewController(); - - const result = await controller.show(); - - expect(mockView.show).toHaveBeenCalledTimes(1); - expect(result).toBe(true); - }); - }); - - describe('hide', () => { - it('should delegate to aiAssistantView hide method', async () => { - const { controller, mockView } = createAIAssistantViewController(); - - const result = await controller.hide(); - - expect(mockView.hide).toHaveBeenCalledTimes(1); - expect(result).toBe(true); - }); - }); -}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_view_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_view_controller.ts deleted file mode 100644 index dfb0e345b13b..000000000000 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_view_controller.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ViewController } from '../m_modules'; -import type { AIAssistantView } from './m_ai_assistant_view'; - -export class AIAssistantViewController extends ViewController { - private aiAssistantView!: AIAssistantView; - - public init(): void { - this.aiAssistantView = this.getView('aiAssistantView'); - } - - public show(): Promise { - return this.aiAssistantView.show(); - } - - public hide(): Promise { - return this.aiAssistantView.hide(); - } -} 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 319dc494630f..037428577025 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 @@ -9,15 +9,16 @@ import { } from '@jest/globals'; import $ from '@js/core/renderer'; import Chat from '@js/ui/chat'; -import Popup from '@js/ui/popup'; +import Popup from '@ts/ui/popup/m_popup'; import { AIChat } from './ai_chat'; import { CLASSES, DEFAULT_POPUP_OPTIONS } from './const'; import type { AIChatOptions } from './types'; const mockPopupInstance = { - show: jest.fn<() => Promise>().mockResolvedValue(true), + toggle: jest.fn<() => Promise>().mockResolvedValue(true), hide: jest.fn<() => Promise>().mockResolvedValue(true), + option: jest.fn<(name: string) => unknown>().mockReturnValue(false), }; const mockChatInstance = {}; @@ -87,13 +88,13 @@ describe('AIChat', () => { }); }); - describe('show', () => { - it('should call popup show method', async () => { + describe('toggle', () => { + it('should call popup toggle method', async () => { const { aiChat } = createAIChat(); - const result = await aiChat.show(); + const result = await aiChat.toggle(); - expect(mockPopupInstance.show).toHaveBeenCalledTimes(1); + expect(mockPopupInstance.toggle).toHaveBeenCalledTimes(1); expect(result).toBe(true); }); }); @@ -108,4 +109,64 @@ describe('AIChat', () => { expect(result).toBe(true); }); }); + + describe('isShown', () => { + it('should return true when popup is visible', () => { + const { aiChat } = createAIChat(); + mockPopupInstance.option.mockReturnValue(true); + + expect(aiChat.isShown()).toBe(true); + expect(mockPopupInstance.option).toHaveBeenCalledWith('visible'); + }); + + it('should return false when popup is not visible', () => { + const { aiChat } = createAIChat(); + mockPopupInstance.option.mockReturnValue(false); + + expect(aiChat.isShown()).toBe(false); + }); + }); + + describe('onVisibilityChanged', () => { + const getPopupConfig = (): any => { + const call = createComponentMock.mock.calls.find( + ([, Widget]) => Widget === Popup, + ); + + expect(call).toBeDefined(); + + return (call as any)[2]; + }; + + it('should call onVisibilityChanged with true on showing', () => { + const onVisibilityChanged = jest.fn(); + createAIChat({ onVisibilityChanged }); + + const popupConfig = getPopupConfig(); + popupConfig.onShowing(); + + expect(onVisibilityChanged).toHaveBeenCalledTimes(1); + expect(onVisibilityChanged).toHaveBeenCalledWith(true); + }); + + it('should call onVisibilityChanged with false on hidden', () => { + const onVisibilityChanged = jest.fn(); + createAIChat({ onVisibilityChanged }); + + const popupConfig = getPopupConfig(); + popupConfig.onHidden(); + + expect(onVisibilityChanged).toHaveBeenCalledTimes(1); + expect(onVisibilityChanged).toHaveBeenCalledWith(false); + }); + + it('should not throw when onVisibilityChanged is not provided', () => { + createAIChat(); + + const popupConfig = getPopupConfig(); + + expect(() => { popupConfig.onShowing(); }).not.toThrow(); + expect(() => { popupConfig.onHidden(); }).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 0705555bcf12..4ff918143c3a 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 @@ -2,7 +2,7 @@ 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 Popup from '@js/ui/popup'; +import Popup from '@ts/ui/popup/m_popup'; import { CLASSES, DEFAULT_POPUP_OPTIONS } from './const'; import type { AIChatOptions } from './types'; @@ -29,6 +29,12 @@ export class AIChat { return { ...DEFAULT_POPUP_OPTIONS, wrapperAttr: { class: `${CLASSES.aiChat} ${CLASSES.aiDialog}` }, + onShowing: (): void => { + this.options.onVisibilityChanged?.(true); + }, + onHidden: (): void => { + this.options.onVisibilityChanged?.(false); + }, contentTemplate: ($container): void => { const $editorContainer = $('
') .addClass(CLASSES.aiChatContent) @@ -43,11 +49,15 @@ export class AIChat { }; } - public show(): Promise { - return this.popupInstance.show(); + public toggle(): Promise { + return this.popupInstance.toggle(); } public hide(): Promise { return this.popupInstance.hide(); } + + public isShown(): boolean { + 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 b775aa9570c5..f41d51efda7d 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 @@ -2,6 +2,8 @@ export const DEFAULT_POPUP_OPTIONS = { width: 360, height: 'auto', visible: false, + shading: false, + showCloseButton: true, }; export const CLASSES = { 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 ec50b55fb31a..eb30cb8b0f93 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 @@ -11,6 +11,7 @@ export interface AIChatOptions { onMessageEntered?: () => void; onChatCleared?: () => void; onRegenerate?: () => void; + onVisibilityChanged?: (visible: boolean) => void; popupOptions?: PopupProperties; chatOptions?: ChatProperties; } diff --git a/packages/devextreme/js/__internal/grids/grid_core/column_chooser/m_column_chooser.ts b/packages/devextreme/js/__internal/grids/grid_core/column_chooser/m_column_chooser.ts index 5d85ba5e8c1c..514bb88c5cec 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/column_chooser/m_column_chooser.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/column_chooser/m_column_chooser.ts @@ -539,7 +539,7 @@ const headerPanel = (Base: ModuleType) => class ColumnChooserHeader that.component.getView('columnChooserView').showColumnChooser(); }; const onInitialized = function (e) { - $(e.element).addClass(that._getToolbarButtonClass(that.addWidgetPrefix(COLUMN_CHOOSER_BUTTON_CLASS))); + $(e.element).addClass(that.getToolbarButtonClass(that.addWidgetPrefix(COLUMN_CHOOSER_BUTTON_CLASS))); }; const hintText = that.option('columnChooser.title'); const toolbarItem = { diff --git a/packages/devextreme/js/__internal/grids/grid_core/editing/m_editing.ts b/packages/devextreme/js/__internal/grids/grid_core/editing/m_editing.ts index 3531fecd28e1..6c5bb483c746 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/editing/m_editing.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/editing/m_editing.ts @@ -2387,7 +2387,7 @@ class EditingControllerImpl extends modules.ViewController { const className = classNameButtonByNames[name]; const onInitialized = (e) => { - $(e.element).addClass(headerPanel._getToolbarButtonClass(`${EDIT_BUTTON_CLASS} ${this.addWidgetPrefix(className)}-button`)); + $(e.element).addClass(headerPanel.getToolbarButtonClass(`${EDIT_BUTTON_CLASS} ${this.addWidgetPrefix(className)}-button`)); }; const hintText = titleButtonTextByClassNames[name]; const isButtonDisabled = (className === 'save' || className === 'cancel') && this._isEditButtonDisabled(); diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter/m_filter_row.ts b/packages/devextreme/js/__internal/grids/grid_core/filter/m_filter_row.ts index 8e145e0f32ea..91e6f9906fdb 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/filter/m_filter_row.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/filter/m_filter_row.ts @@ -989,7 +989,7 @@ const headerPanel = (Base: ModuleType) => class FilterRowHeaderPane const columns = that._columnsController.getColumns(); const disabled = !columns.filter((column) => column.bufferedFilterValue !== undefined).length; const onInitialized = function (e) { - $(e.element).addClass(that._getToolbarButtonClass(APPLY_BUTTON_CLASS)); + $(e.element).addClass(that.getToolbarButtonClass(APPLY_BUTTON_CLASS)); }; const onClickHandler = function () { that._applyFilterViewController.applyFilter(); diff --git a/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts b/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts index 81ca5323bfa1..a3a06d9bcde8 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts @@ -120,7 +120,7 @@ export class HeaderPanel extends ColumnsView { return $('
').addClass(this.addWidgetPrefix(TOOLBAR_BUTTON_CLASS)); } - protected _getToolbarButtonClass(specificClass) { + public getToolbarButtonClass(specificClass?: string): string { const secondClass = specificClass ? ` ${specificClass}` : ''; return this.addWidgetPrefix(TOOLBAR_BUTTON_CLASS) + secondClass; 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 6137c9802937..9eb4dc60f88f 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_types.ts @@ -127,6 +127,12 @@ export interface InternalGridOptions extends GridBaseOptions