Skip to content

Commit f1310af

Browse files
authored
DataGrid - AI Assistant: Add abort confirmation dialog (#33607)
Co-authored-by: Alyar <>
1 parent 6954c66 commit f1310af

42 files changed

Lines changed: 461 additions & 25 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/devextreme-scss/scss/widgets/base/gridBase/_index.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,4 +1041,8 @@
10411041
left: 0;
10421042
transform: translateY(-50%);
10431043
}
1044+
1045+
.dx-#{$widget-name}-ai-assistant-confirm-dialog .dx-dialog-button {
1046+
width: $grid-ai-assistant-confirm-dialog-button-width;
1047+
}
10441048
}

packages/devextreme-scss/scss/widgets/base/gridBase/_variables.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ $grid-column-header-indicator-width: 14px;
1010
$grid-text-content-margin: 3px;
1111
$grid-sort-index-width: 12px;
1212
$grid-sort-index-offset: 3px;
13+
$grid-ai-assistant-confirm-dialog-button-width: 50px;

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,75 @@ describe('AIAssistantController', () => {
9999
});
100100
});
101101

102+
describe('isProcessing', () => {
103+
it('should return false initially', () => {
104+
const controller = createController();
105+
106+
expect(controller.isProcessing()).toBe(false);
107+
});
108+
109+
it('should return true while request is processing', () => {
110+
const controller = createController();
111+
112+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
113+
controller.sendRequestToAI({
114+
author: { id: 'user', name: 'User' },
115+
text: 'Sort by name',
116+
timestamp: '2026-04-16T10:00:00.000Z',
117+
} as Message);
118+
119+
expect(controller.isProcessing()).toBe(true);
120+
});
121+
122+
it('should return false after request completes successfully', async () => {
123+
const controller = createController();
124+
125+
const promise = controller.sendRequestToAI({
126+
author: { id: 'user', name: 'User' },
127+
text: 'Sort by name',
128+
timestamp: '2026-04-16T10:00:00.000Z',
129+
} as Message);
130+
131+
const actions = [{ name: 'sort', args: { column: 'Name' } }];
132+
sendRequestCallbacks.onComplete?.({ actions });
133+
await promise;
134+
135+
expect(controller.isProcessing()).toBe(false);
136+
});
137+
138+
it('should return false after request fails', async () => {
139+
const controller = createController();
140+
141+
const promise = controller.sendRequestToAI({
142+
author: { id: 'user', name: 'User' },
143+
text: 'Sort by name',
144+
timestamp: '2026-04-16T10:00:00.000Z',
145+
} as Message);
146+
promise.catch(() => {});
147+
148+
sendRequestCallbacks.onError?.(new Error('Network error'));
149+
await expect(promise).rejects.toThrow('Network error');
150+
151+
expect(controller.isProcessing()).toBe(false);
152+
});
153+
154+
it('should return false after request is aborted', async () => {
155+
const controller = createController();
156+
157+
const promise = controller.sendRequestToAI({
158+
author: { id: 'user', name: 'User' },
159+
text: 'Sort by name',
160+
timestamp: '2026-04-16T10:00:00.000Z',
161+
} as Message);
162+
promise.catch(() => {});
163+
164+
controller.abortRequest();
165+
await expect(promise).rejects.toThrow();
166+
167+
expect(controller.isProcessing()).toBe(false);
168+
});
169+
});
170+
102171
describe('sendRequestToAI', () => {
103172
it('should create pending message in store', async () => {
104173
const controller = createController();

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view.test.ts

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ import wrapInstanceWithMocks from '@ts/grids/grid_core/__tests__/__mock__/helper
1616
import { AIChat } from '../../ai_chat/ai_chat';
1717
import type { AIChatOptions } from '../../ai_chat/types';
1818
import { AIAssistantView } from '../ai_assistant_view';
19+
import { createConfirmDialog } from '../utils';
20+
21+
jest.mock('../utils', (): any => {
22+
const original = jest.requireActual<any>('../utils');
23+
24+
return {
25+
...original,
26+
createConfirmDialog: jest.fn(),
27+
};
28+
});
1929

2030
jest.mock('../../ai_chat/ai_chat', (): any => {
2131
const original = jest.requireActual<any>('../../ai_chat/ai_chat');
@@ -40,6 +50,7 @@ const mockAIAssistantController = {
4050
getMessageStore: jest.fn().mockReturnValue(mockMessageStore),
4151
sendRequestToAI: jest.fn(),
4252
abortRequest: jest.fn(),
53+
isProcessing: jest.fn().mockReturnValue(false),
4354
};
4455

4556
const createAIAssistantView = ({
@@ -75,6 +86,7 @@ const createAIAssistantView = ({
7586
};
7687

7788
const mockComponent = {
89+
NAME: 'dxDataGrid',
7890
element: (): any => $container.get(0),
7991
_createComponent: createComponentMock,
8092
_controllers: {
@@ -304,14 +316,98 @@ describe('AIAssistantView', () => {
304316
});
305317
});
306318

307-
describe('onHidden', () => {
308-
it('should call abortRequest on controller when popup onHidden is triggered', () => {
319+
describe('onHiding', () => {
320+
it('should not cancel hiding when controller is not processing', () => {
321+
mockAIAssistantController.isProcessing.mockReturnValue(false);
309322
createAIAssistantView();
310323

311324
const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions;
312-
aiChatConfig.popupOptions?.onHidden?.({} as any);
325+
const event = { cancel: false, component: { hide: jest.fn() } };
326+
327+
aiChatConfig.popupOptions?.onHiding?.(event as any);
328+
329+
expect(event.cancel).toBe(false);
330+
expect(createConfirmDialog).not.toHaveBeenCalled();
331+
});
332+
333+
it('should cancel hiding and show confirm dialog when controller is processing', () => {
334+
mockAIAssistantController.isProcessing.mockReturnValue(true);
335+
336+
const mockDialog = {
337+
show: jest.fn().mockReturnValue({
338+
done: jest.fn(),
339+
}),
340+
};
341+
(createConfirmDialog as jest.Mock).mockReturnValue(mockDialog);
342+
createAIAssistantView();
343+
344+
const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions;
345+
const event = { cancel: false, component: { hide: jest.fn() } };
346+
347+
aiChatConfig.popupOptions?.onHiding?.(event as any);
348+
349+
expect(event.cancel).toBe(true);
350+
expect(createConfirmDialog).toHaveBeenCalledTimes(1);
351+
expect(createConfirmDialog).toHaveBeenCalledWith(
352+
expect.objectContaining({
353+
popupOptions: expect.objectContaining({
354+
elementAttr: expect.objectContaining({
355+
class: expect.stringContaining('ai-assistant-confirm-dialog'),
356+
}),
357+
}),
358+
}),
359+
);
360+
expect(mockDialog.show).toHaveBeenCalledTimes(1);
361+
});
362+
363+
it('should abort request and hide popup when confirm result is true', () => {
364+
mockAIAssistantController.isProcessing.mockReturnValue(true);
365+
366+
let doneCallback: (result: boolean) => void = () => {};
367+
const mockDialog = {
368+
show: jest.fn().mockReturnValue({
369+
done: jest.fn((cb: (result: boolean) => void) => {
370+
doneCallback = cb;
371+
}),
372+
}),
373+
};
374+
(createConfirmDialog as jest.Mock).mockReturnValue(mockDialog);
375+
createAIAssistantView();
376+
377+
const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions;
378+
const hideMock = jest.fn();
379+
const event = { cancel: false, component: { hide: hideMock } };
380+
381+
aiChatConfig.popupOptions?.onHiding?.(event as any);
382+
doneCallback(true);
313383

314384
expect(mockAIAssistantController.abortRequest).toHaveBeenCalledTimes(1);
385+
expect(hideMock).toHaveBeenCalledTimes(1);
386+
});
387+
388+
it('should not abort request when confirm result is false', () => {
389+
mockAIAssistantController.isProcessing.mockReturnValue(true);
390+
391+
let doneCallback: (result: boolean) => void = () => {};
392+
const mockDialog = {
393+
show: jest.fn().mockReturnValue({
394+
done: jest.fn((cb: (result: boolean) => void) => {
395+
doneCallback = cb;
396+
}),
397+
}),
398+
};
399+
(createConfirmDialog as jest.Mock).mockReturnValue(mockDialog);
400+
createAIAssistantView();
401+
402+
const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions;
403+
const hideMock = jest.fn();
404+
const event = { cancel: false, component: { hide: hideMock } };
405+
406+
aiChatConfig.popupOptions?.onHiding?.(event as any);
407+
doneCallback(false);
408+
409+
expect(mockAIAssistantController.abortRequest).not.toHaveBeenCalled();
410+
expect(hideMock).not.toHaveBeenCalled();
315411
});
316412
});
317413

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_view_controller.integration.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,8 +390,17 @@ describe('AIAssistantViewController', () => {
390390

391391
const viewController = instance.getController('aiAssistantViewController');
392392

393-
// Close the AI assistant popup
394-
await viewController.toggle();
393+
// Close the AI assistant popup — triggers confirm dialog because request is processing.
394+
// toggle() rejects with undefined when onHiding cancels the hide.
395+
await viewController.toggle().catch(() => {});
396+
jest.runAllTimers();
397+
await flushAsync();
398+
399+
// Confirm abort by clicking "Yes" in the confirmation dialog
400+
const confirmDialogSelector = '.dx-datagrid-ai-assistant-confirm-dialog';
401+
const yesButton = document.querySelectorAll(`${confirmDialogSelector} .dx-button`)[1] as HTMLElement;
402+
403+
yesButton.click();
395404
jest.runAllTimers();
396405
await flushAsync();
397406

0 commit comments

Comments
 (0)