From 1ae5f62e3b60fa76bb669fc813cd36dea9d22279 Mon Sep 17 00:00:00 2001 From: "anna.shakhova" Date: Tue, 7 Apr 2026 14:02:53 +0200 Subject: [PATCH 1/4] GridCore: add ai assistant toolbar item --- .../module_not_extended/ai_assistant.ts | 8 + ...istant_view_controller.integration.test.ts | 193 ++++++++++++++++++ .../ai_assistant/m_ai_assistant_view.ts | 6 +- .../m_ai_assistant_view_controller.test.ts | 79 ------- .../m_ai_assistant_view_controller.ts | 77 +++++++ .../grids/grid_core/ai_chat/ai_chat.ts | 6 +- .../grids/grid_core/ai_chat/const.ts | 1 + .../grid_core/header_panel/m_header_panel.ts | 2 +- .../js/__internal/grids/grid_core/m_types.ts | 6 + .../module_not_extended/ai_assistant.ts | 8 + 10 files changed, 304 insertions(+), 82 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view_controller.integration.test.ts delete mode 100644 packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_view_controller.test.ts 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..bd3aa128b865 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 @@ -4,6 +4,14 @@ import { AIAssistantViewController } from '@ts/grids/grid_core/ai_assistant/m_ai 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/__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..8d19b0964412 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view_controller.integration.test.ts @@ -0,0 +1,193 @@ +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'); + }); + }); + + describe('show / hide / toggle', () => { + it('should delegate show to aiAssistantView', async () => { + const { instance } = await createDataGrid({ + dataSource: [{ id: 1 }], + aiAssistant: { enabled: true, title: 'AI Assistant' }, + }); + + const controller = instance.getController('aiAssistant'); + const view = (controller as any).aiAssistantView; + const showSpy = jest.spyOn(view, 'show'); + + await controller.show(); + + expect(showSpy).toHaveBeenCalledTimes(1); + }); + + it('should delegate hide to aiAssistantView', async () => { + const { instance } = await createDataGrid({ + dataSource: [{ id: 1 }], + aiAssistant: { enabled: true, title: 'AI Assistant' }, + }); + + const controller = instance.getController('aiAssistant'); + const view = (controller as any).aiAssistantView; + const hideSpy = jest.spyOn(view, 'hide'); + + await controller.hide(); + + expect(hideSpy).toHaveBeenCalledTimes(1); + }); + + it('should delegate toggle to aiAssistantView', async () => { + const { instance } = await createDataGrid({ + dataSource: [{ id: 1 }], + aiAssistant: { enabled: true, title: 'AI Assistant' }, + }); + + const controller = instance.getController('aiAssistant'); + const view = (controller as any).aiAssistantView; + const toggleSpy = jest.spyOn(view, 'toggle'); + + await controller.toggle(); + + expect(toggleSpy).toHaveBeenCalledTimes(1); + }); + }); +}); 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/m_ai_assistant_view.ts index 4e19e58f0425..2f1e2f8dc83c 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/m_ai_assistant_view.ts @@ -21,7 +21,7 @@ export class AIAssistantView extends View { } public isVisible(): boolean { - return this.option('aiAssistant.enabled'); + return !!this.option('aiAssistant.enabled'); } public show(): Promise { @@ -31,4 +31,8 @@ export class AIAssistantView extends View { 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/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 index dfb0e345b13b..dfce1eee633a 100644 --- 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 @@ -1,11 +1,44 @@ +import $ from '@js/core/renderer'; +import type { InitializedEvent as ButtonInitializedEvent } from '@js/ui/button'; +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 './m_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; + public init(): void { this.aiAssistantView = this.getView('aiAssistantView'); + this.headerPanel = this.getView('headerPanel'); + + const isAiAssistantEnabled = this.option('aiAssistant.enabled'); // TODO clarify option name + + if (isAiAssistantEnabled) { + const aiAssistantToolbarItem = this.getAiAssistantToolbarItem(); + + this.headerPanel?.registerToolbarItem(AI_ASSISTANT_BUTTON_NAME, aiAssistantToolbarItem); + } + } + + public optionChanged(args: OptionChanged): void { + if (args.name === 'aiAssistant') { + if (args.fullName === 'aiAssistant.enabled') { // TODO clarify option name + this.syncAiAssistantItem(); + } + + args.handled = true; + } else { + super.optionChanged(args); + } } public show(): Promise { @@ -15,4 +48,48 @@ export class AIAssistantViewController extends ViewController { public hide(): Promise { return this.aiAssistantView.hide(); } + + 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.getAiAssistantToolbarItem(); + + this.headerPanel?.applyToolbarItem(AI_ASSISTANT_BUTTON_NAME, aiAssistantToolbarItem); + } else { + this.headerPanel?.removeToolbarItem(AI_ASSISTANT_BUTTON_NAME); + } + } + + private getAiAssistantToolbarItem(): ToolbarItem { + const onClickHandler = (): Promise => this.toggle(); + + const onInitialized = (e: ButtonInitializedEvent): void => { + if (this.headerPanel) { + const asAssistantClass = this.addWidgetPrefix(AI_ASSISTANT_BUTTON_CLASS); + $(e.element).addClass(this.headerPanel._getToolbarButtonClass(asAssistantClass)); + } + }; + const hintText = this.option('aiAssistant.title'); // TODO clarify option name + return { + widget: 'dxButton', + options: { + icon: AI_ASSISTANT_ICON_NAME, + 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_chat/ai_chat.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.ts index 0705555bcf12..ba53b8e27f4c 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'; @@ -50,4 +50,8 @@ export class AIChat { public hide(): Promise { return this.popupInstance.hide(); } + + public toggle(): Promise { + return this.popupInstance.toggle(); + } } 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..86bcaf986dca 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,7 @@ export const DEFAULT_POPUP_OPTIONS = { width: 360, height: 'auto', visible: false, + shading: false, }; export const CLASSES = { 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..a937f6473da0 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..728d825c332e 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 Date: Tue, 7 Apr 2026 14:08:05 +0200 Subject: [PATCH 2/4] GridCore: remove m_ prefix from new modules file names --- .../grids/data_grid/module_not_extended/ai_assistant.ts | 4 ++-- .../ai_assistant_view.test.ts} | 8 ++++---- .../{m_ai_assistant_view.ts => ai_assistant_view.ts} | 0 ...view_controller.ts => ai_assistant_view_controller.ts} | 2 +- .../devextreme/js/__internal/grids/grid_core/m_types.ts | 4 ++-- .../grids/tree_list/module_not_extended/ai_assistant.ts | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) rename packages/devextreme/js/__internal/grids/grid_core/ai_assistant/{m_ai_assistant_view.test.ts => __tests__/ai_assistant_view.test.ts} (96%) rename packages/devextreme/js/__internal/grids/grid_core/ai_assistant/{m_ai_assistant_view.ts => ai_assistant_view.ts} (100%) rename packages/devextreme/js/__internal/grids/grid_core/ai_assistant/{m_ai_assistant_view_controller.ts => ai_assistant_view_controller.ts} (97%) 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 bd3aa128b865..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,5 +1,5 @@ -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'; 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 96% 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..c04cac63d67e 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,11 @@ 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 { 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, 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 100% 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 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/ai_assistant_view_controller.ts similarity index 97% rename from packages/devextreme/js/__internal/grids/grid_core/ai_assistant/m_ai_assistant_view_controller.ts rename to packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view_controller.ts index dfce1eee633a..31c285409b8a 100644 --- 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/ai_assistant_view_controller.ts @@ -5,7 +5,7 @@ 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 './m_ai_assistant_view'; +import type { AIAssistantView } from './ai_assistant_view'; const AI_ASSISTANT_BUTTON_NAME = 'aiAssistantButton'; const AI_ASSISTANT_BUTTON_CLASS = 'ai-assistant-button'; 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 728d825c332e..9eb4dc60f88f 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_types.ts @@ -212,7 +212,7 @@ export interface Controllers { toastViewController: import('./toast/m_toast_controller').ToastViewController; aiColumn: import('./ai_column/controllers/m_ai_column_controller').AIColumnController; aiPromptEditor: import('./ai_column/controllers/m_ai_prompt_editor_view_controller').AIPromptEditorViewController; - aiAssistant: import('./ai_assistant/m_ai_assistant_view_controller').AIAssistantViewController; + aiAssistant: import('./ai_assistant/ai_assistant_view_controller').AIAssistantViewController; } type ControllerTypes = { @@ -237,7 +237,7 @@ export interface Views { filterPanelView: import('./filter/m_filter_panel').FilterPanelView; toastView: import('./toast/m_toast_view').ToastView; aiPromptEditorView: import('./ai_column/views/m_ai_prompt_editor_view').AIPromptEditorView; - aiAssistantView: import('./ai_assistant/m_ai_assistant_view').AIAssistantView; + aiAssistantView: import('./ai_assistant/ai_assistant_view').AIAssistantView; } export interface EditingControllerRequired { diff --git a/packages/devextreme/js/__internal/grids/tree_list/module_not_extended/ai_assistant.ts b/packages/devextreme/js/__internal/grids/tree_list/module_not_extended/ai_assistant.ts index bd3aa128b865..023e3f36b4dd 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/module_not_extended/ai_assistant.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/module_not_extended/ai_assistant.ts @@ -1,5 +1,5 @@ -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'; From 45124c67c62c2a3aa2f324e8b04d27fabb7b03c5 Mon Sep 17 00:00:00 2001 From: "anna.shakhova" Date: Tue, 7 Apr 2026 16:14:21 +0200 Subject: [PATCH 3/4] set ai assistant button to active state when popup visible, refactor --- .../__tests__/ai_assistant_view.test.ts | 55 +++++--- ...istant_view_controller.integration.test.ts | 47 ------- .../ai_assistant_view_controller.test.ts | 120 ++++++++++++++++++ .../ai_assistant/ai_assistant_view.ts | 13 +- .../ai_assistant_view_controller.ts | 35 ++--- .../grids/grid_core/ai_chat/ai_chat.test.ts | 30 +++-- .../grids/grid_core/ai_chat/ai_chat.ts | 18 +-- .../grids/grid_core/ai_chat/types.ts | 1 + 8 files changed, 210 insertions(+), 109 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view_controller.test.ts 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 c04cac63d67e..7f62aa2d3cb9 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 @@ -13,6 +13,7 @@ import $ from '@js/core/renderer'; import wrapInstanceWithMocks from '@ts/grids/grid_core/__tests__/__mock__/helpers/wrapInstance'; 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 => { @@ -152,45 +153,61 @@ 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); }); }); - describe('hide', () => { - it('should delegate to AIChat hide method', async () => { + describe('isShown', () => { + it('should delegate to AIChat isShown method', () => { const { aiAssistantView } = createAIAssistantView(); - 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 { isShown: jest.Mock }; - expect(aiChatInstance.hide).toHaveBeenCalledTimes(1); + aiChatInstance.isShown.mockReturnValue(true); + expect(aiAssistantView.isShown()).toBe(true); + + aiChatInstance.isShown.mockReturnValue(false); + expect(aiAssistantView.isShown()).toBe(false); }); - }); - describe('show when not initialized', () => { - it('should return resolved false promise when aiChatInstance is not created', () => { + it('should return false when aiChatInstance is not created', () => { const { aiAssistantView } = createAIAssistantView({ render: false }); - return expect(aiAssistantView.show()).resolves.toBe(false); + expect(aiAssistantView.isShown()).toBe(false); }); }); - describe('hide when not initialized', () => { - it('should return resolved false promise when aiChatInstance is not created', () => { - const { aiAssistantView } = createAIAssistantView({ render: 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); - return expect(aiAssistantView.hide()).resolves.toBe(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 index 8d19b0964412..8507eb20f77b 100644 --- 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 @@ -143,51 +143,4 @@ describe('AIAssistantViewController', () => { expect(button?.getAttribute('title')).toBe('My Custom Title'); }); }); - - describe('show / hide / toggle', () => { - it('should delegate show to aiAssistantView', async () => { - const { instance } = await createDataGrid({ - dataSource: [{ id: 1 }], - aiAssistant: { enabled: true, title: 'AI Assistant' }, - }); - - const controller = instance.getController('aiAssistant'); - const view = (controller as any).aiAssistantView; - const showSpy = jest.spyOn(view, 'show'); - - await controller.show(); - - expect(showSpy).toHaveBeenCalledTimes(1); - }); - - it('should delegate hide to aiAssistantView', async () => { - const { instance } = await createDataGrid({ - dataSource: [{ id: 1 }], - aiAssistant: { enabled: true, title: 'AI Assistant' }, - }); - - const controller = instance.getController('aiAssistant'); - const view = (controller as any).aiAssistantView; - const hideSpy = jest.spyOn(view, 'hide'); - - await controller.hide(); - - expect(hideSpy).toHaveBeenCalledTimes(1); - }); - - it('should delegate toggle to aiAssistantView', async () => { - const { instance } = await createDataGrid({ - dataSource: [{ id: 1 }], - aiAssistant: { enabled: true, title: 'AI Assistant' }, - }); - - const controller = instance.getController('aiAssistant'); - const view = (controller as any).aiAssistantView; - const toggleSpy = jest.spyOn(view, 'toggle'); - - await controller.toggle(); - - expect(toggleSpy).toHaveBeenCalledTimes(1); - }); - }); }); 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..88cf8bf72260 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view_controller.test.ts @@ -0,0 +1,120 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; + +import { AIAssistantViewController } from '../ai_assistant_view_controller'; + +interface MockAIAssistantView { + toggle: jest.Mock<() => Promise>; + isShown: jest.Mock<() => boolean>; + onVisibilityChanged?: (visible: boolean) => void; +} + +interface MockHeaderPanel { + registerToolbarItem: jest.Mock; + applyToolbarItem: jest.Mock; + removeToolbarItem: jest.Mock; +} + +const createMockAIAssistantView = (): MockAIAssistantView => ({ + toggle: jest.fn<() => Promise>().mockResolvedValue(true), + isShown: jest.fn<() => boolean>().mockReturnValue(false), +}); + +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); + }); + }); +}); 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 2f1e2f8dc83c..024e2b262395 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 @@ -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); + }, }; } @@ -24,12 +29,8 @@ export class AIAssistantView extends View { return !!this.option('aiAssistant.enabled'); } - public show(): Promise { - return this.aiChatInstance?.show() ?? Promise.resolve(false); - } - - public hide(): Promise { - return this.aiChatInstance?.hide() ?? Promise.resolve(false); + public isShown(): boolean { + return this.aiChatInstance?.isShown() ?? false; } public toggle(): Promise { 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 31c285409b8a..175ba28d7fef 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 @@ -1,5 +1,7 @@ +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'; @@ -16,14 +18,20 @@ export class AIAssistantViewController extends ViewController { 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.getAiAssistantToolbarItem(); + const aiAssistantToolbarItem = this.createAiAssistantToolbarItem(); this.headerPanel?.registerToolbarItem(AI_ASSISTANT_BUTTON_NAME, aiAssistantToolbarItem); } @@ -31,24 +39,13 @@ export class AIAssistantViewController extends ViewController { public optionChanged(args: OptionChanged): void { if (args.name === 'aiAssistant') { - if (args.fullName === 'aiAssistant.enabled') { // TODO clarify option name - this.syncAiAssistantItem(); - } - + this.syncAiAssistantItem(); args.handled = true; } else { super.optionChanged(args); } } - public show(): Promise { - return this.aiAssistantView.show(); - } - - public hide(): Promise { - return this.aiAssistantView.hide(); - } - public toggle(): Promise { return this.aiAssistantView.toggle(); } @@ -57,7 +54,7 @@ export class AIAssistantViewController extends ViewController { const isAiAssistantEnabled = this.option('aiAssistant.enabled'); // TODO clarify option name if (isAiAssistantEnabled) { - const aiAssistantToolbarItem = this.getAiAssistantToolbarItem(); + const aiAssistantToolbarItem = this.createAiAssistantToolbarItem(); this.headerPanel?.applyToolbarItem(AI_ASSISTANT_BUTTON_NAME, aiAssistantToolbarItem); } else { @@ -65,20 +62,24 @@ export class AIAssistantViewController extends ViewController { } } - private getAiAssistantToolbarItem(): ToolbarItem { + private createAiAssistantToolbarItem(): ToolbarItem { const onClickHandler = (): Promise => this.toggle(); const onInitialized = (e: ButtonInitializedEvent): void => { + this.$aiAssistantButton = $(e.element); + if (this.headerPanel) { - const asAssistantClass = this.addWidgetPrefix(AI_ASSISTANT_BUTTON_CLASS); - $(e.element).addClass(this.headerPanel._getToolbarButtonClass(asAssistantClass)); + 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, 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..c8237ec73227 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,15 @@ 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), - hide: jest.fn<() => Promise>().mockResolvedValue(true), + toggle: jest.fn<() => Promise>().mockResolvedValue(true), + option: jest.fn<(name: string) => unknown>().mockReturnValue(false), }; const mockChatInstance = {}; @@ -87,25 +87,31 @@ 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); }); }); - describe('hide', () => { - it('should call popup hide method', async () => { + describe('isShown', () => { + it('should return true when popup is visible', () => { const { aiChat } = createAIChat(); + mockPopupInstance.option.mockReturnValue(true); - const result = await aiChat.hide(); + expect(aiChat.isShown()).toBe(true); + expect(mockPopupInstance.option).toHaveBeenCalledWith('visible'); + }); - expect(mockPopupInstance.hide).toHaveBeenCalledTimes(1); - expect(result).toBe(true); + it('should return false when popup is not visible', () => { + const { aiChat } = createAIChat(); + mockPopupInstance.option.mockReturnValue(false); + + expect(aiChat.isShown()).toBe(false); }); }); }); 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 ba53b8e27f4c..591837f9a93a 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 @@ -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,15 +49,11 @@ export class AIChat { }; } - public show(): Promise { - return this.popupInstance.show(); - } - - public hide(): Promise { - return this.popupInstance.hide(); - } - public toggle(): Promise { return this.popupInstance.toggle(); } + + public isShown(): boolean { + return !!this.popupInstance.option('visible'); + } } 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; } From 81ee6893b96973b2be61fb9788ef3a21d2179cdb Mon Sep 17 00:00:00 2001 From: "anna.shakhova" Date: Tue, 7 Apr 2026 16:49:11 +0200 Subject: [PATCH 4/4] copilot review fix --- .../__tests__/ai_assistant_view.test.ts | 19 +++++++ .../ai_assistant_view_controller.test.ts | 57 ++++++++++++++++++- .../ai_assistant/ai_assistant_view.ts | 4 ++ .../ai_assistant_view_controller.ts | 5 +- .../grids/grid_core/ai_chat/ai_chat.test.ts | 55 ++++++++++++++++++ .../grids/grid_core/ai_chat/ai_chat.ts | 4 ++ .../grids/grid_core/ai_chat/const.ts | 1 + .../column_chooser/m_column_chooser.ts | 2 +- .../grids/grid_core/editing/m_editing.ts | 2 +- .../grids/grid_core/filter/m_filter_row.ts | 2 +- .../grid_core/header_panel/m_header_panel.ts | 2 +- 11 files changed, 146 insertions(+), 7 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view.test.ts index 7f62aa2d3cb9..3499d766fe88 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 @@ -172,6 +172,25 @@ describe('AIAssistantView', () => { }); }); + describe('hide', () => { + it('should delegate to AIChat hide method', async () => { + const { aiAssistantView } = createAIAssistantView(); + + await aiAssistantView.hide(); + + const aiChatInstance = (AIChat as jest.Mock) + .mock.results[0].value as { hide: jest.Mock }; + + expect(aiChatInstance.hide).toHaveBeenCalledTimes(1); + }); + + it('should return resolved false promise when aiChatInstance is not created', () => { + const { aiAssistantView } = createAIAssistantView({ render: false }); + + return expect(aiAssistantView.hide()).resolves.toBe(false); + }); + }); + describe('isShown', () => { it('should delegate to AIChat isShown method', () => { const { aiAssistantView } = createAIAssistantView(); 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 88cf8bf72260..43f672d9c942 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,7 +11,8 @@ import { AIAssistantViewController } from '../ai_assistant_view_controller'; interface MockAIAssistantView { toggle: jest.Mock<() => Promise>; - isShown: jest.Mock<() => boolean>; + hide: jest.Mock<() => Promise>; + _invalidate: jest.Mock; onVisibilityChanged?: (visible: boolean) => void; } @@ -23,7 +24,8 @@ interface MockHeaderPanel { const createMockAIAssistantView = (): MockAIAssistantView => ({ toggle: jest.fn<() => Promise>().mockResolvedValue(true), - isShown: jest.fn<() => boolean>().mockReturnValue(false), + hide: jest.fn<() => Promise>().mockResolvedValue(true), + _invalidate: jest.fn(), }); const createMockHeaderPanel = (): MockHeaderPanel => ({ @@ -116,5 +118,56 @@ describe('AIAssistantViewController', () => { 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/ai_assistant_view.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_view.ts index 024e2b262395..13d5cf0cc5a0 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 @@ -33,6 +33,10 @@ export class AIAssistantView extends View { 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 index 175ba28d7fef..de334082a84b 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 @@ -57,8 +57,11 @@ export class AIAssistantViewController extends ViewController { 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(); } } @@ -70,7 +73,7 @@ export class AIAssistantViewController extends ViewController { if (this.headerPanel) { const aiAssistantClass = this.addWidgetPrefix(AI_ASSISTANT_BUTTON_CLASS); - this.$aiAssistantButton.addClass(this.headerPanel._getToolbarButtonClass(aiAssistantClass)); + this.$aiAssistantButton.addClass(this.headerPanel.getToolbarButtonClass(aiAssistantClass)); } }; const hintText = this.option('aiAssistant.title'); // TODO clarify option name 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 c8237ec73227..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 @@ -17,6 +17,7 @@ import type { AIChatOptions } from './types'; const mockPopupInstance = { toggle: jest.fn<() => Promise>().mockResolvedValue(true), + hide: jest.fn<() => Promise>().mockResolvedValue(true), option: jest.fn<(name: string) => unknown>().mockReturnValue(false), }; @@ -98,6 +99,17 @@ describe('AIChat', () => { }); }); + describe('hide', () => { + it('should call popup hide method', async () => { + const { aiChat } = createAIChat(); + + const result = await aiChat.hide(); + + expect(mockPopupInstance.hide).toHaveBeenCalledTimes(1); + expect(result).toBe(true); + }); + }); + describe('isShown', () => { it('should return true when popup is visible', () => { const { aiChat } = createAIChat(); @@ -114,4 +126,47 @@ describe('AIChat', () => { 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 591837f9a93a..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 @@ -53,6 +53,10 @@ export class AIChat { 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 86bcaf986dca..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 @@ -3,6 +3,7 @@ export const DEFAULT_POPUP_OPTIONS = { height: 'auto', visible: false, shading: false, + showCloseButton: true, }; export const CLASSES = { 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 a937f6473da0..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)); } - public _getToolbarButtonClass(specificClass?: string): string { + public getToolbarButtonClass(specificClass?: string): string { const secondClass = specificClass ? ` ${specificClass}` : ''; return this.addWidgetPrefix(TOOLBAR_BUTTON_CLASS) + secondClass;