Skip to content

Commit 2d07d7a

Browse files
authored
DataGrid - AI Assistant: Support Store Push API (#33584)
Co-authored-by: Alyar <>
1 parent b6c5b43 commit 2d07d7a

8 files changed

Lines changed: 260 additions & 20 deletions

File tree

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

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,9 @@ const createController = (
5151
return controller;
5252
};
5353

54-
const getStore = (controller: AIAssistantController): ArrayStore<Message, string> => {
55-
const dataSource = controller.getMessageDataSource() as { store: ArrayStore<Message, string> };
56-
return dataSource.store;
57-
};
54+
const getStore = (
55+
controller: AIAssistantController,
56+
): ArrayStore<Message, string> => controller.getMessageStore();
5857

5958
describe('AIAssistantController', () => {
6059
beforeEach(() => {
@@ -91,14 +90,12 @@ describe('AIAssistantController', () => {
9190
);
9291
});
9392

94-
describe('getMessageDataSource', () => {
95-
it('should return dataSource with store', () => {
93+
describe('getMessageStore', () => {
94+
it('should return message store', () => {
9695
const controller = createController();
97-
const dataSource = controller.getMessageDataSource() as {
98-
store: ArrayStore<Message, string>;
99-
};
96+
const store = controller.getMessageStore();
10097

101-
expect(dataSource.store).toBeDefined();
98+
expect(store).toBeDefined();
10299
});
103100
});
104101

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

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ const createComponentMock = jest.fn((
3535
options: any,
3636
): any => new Widget(el, options));
3737

38-
const mockMessageDataSource = { store: new ArrayStore({ key: 'id' }), reshapeOnPush: true };
38+
const mockMessageStore = new ArrayStore({ key: 'id' });
3939
const mockAIAssistantController = {
40-
getMessageDataSource: jest.fn().mockReturnValue(mockMessageDataSource),
40+
getMessageStore: jest.fn().mockReturnValue(mockMessageStore),
4141
sendRequestToAI: jest.fn(),
4242
abortRequest: jest.fn(),
4343
};
@@ -110,6 +110,7 @@ const beforeTest = (): void => {
110110
};
111111

112112
const afterTest = (): void => {
113+
mockMessageStore.off('push');
113114
document.body.innerHTML = '';
114115
fx.off = false;
115116
jest.useRealTimers();
@@ -159,9 +160,9 @@ describe('AIAssistantView', () => {
159160

160161
const aiChatConfig = (AIChat as jest.Mock).mock.calls[0][0] as AIChatOptions;
161162

162-
expect(mockAIAssistantController.getMessageDataSource).toHaveBeenCalledTimes(1);
163+
expect(mockAIAssistantController.getMessageStore).toHaveBeenCalledTimes(1);
163164
expect(aiChatConfig.chatOptions).toEqual(expect.objectContaining({
164-
dataSource: mockMessageDataSource,
165+
dataSource: mockMessageStore,
165166
reloadOnChange: true,
166167
onMessageEntered: expect.any(Function),
167168
}));
@@ -233,6 +234,26 @@ describe('AIAssistantView', () => {
233234
});
234235
});
235236

237+
describe('dispose', () => {
238+
it('should unsubscribe from store push event', () => {
239+
const onSpy = jest.spyOn(mockMessageStore, 'on');
240+
const { aiAssistantView } = createAIAssistantView();
241+
const offSpy = jest.spyOn(mockMessageStore, 'off');
242+
243+
aiAssistantView.dispose();
244+
245+
const onPushCall = (onSpy.mock.calls as any[][]).find((call) => call[0] === 'push');
246+
const offPushCall = (offSpy.mock.calls as any[][]).find((call) => call[0] === 'push');
247+
248+
expect(onPushCall).toBeDefined();
249+
expect(offPushCall).toBeDefined();
250+
expect((offPushCall as any[])[1]).toBe((onPushCall as any[])[1]);
251+
252+
offSpy.mockRestore();
253+
onSpy.mockRestore();
254+
});
255+
});
256+
236257
describe('isShown', () => {
237258
it('should delegate to AIChat isShown method', () => {
238259
const { aiAssistantView } = createAIAssistantView();
@@ -460,6 +481,119 @@ describe('AIAssistantView', () => {
460481
expect(aiChatInstance.setDisabled).toHaveBeenLastCalledWith(false);
461482
});
462483
});
484+
485+
describe('handleMessageStorePush', () => {
486+
const USER_ID = 'user';
487+
488+
const getPushHandler = (): (changes: any[]) => void => {
489+
const onSpy = jest.spyOn(mockMessageStore, 'on');
490+
createAIAssistantView();
491+
492+
const aiChatInstance = (AIChat as jest.Mock)
493+
.mock.results[0].value as { getUserId: jest.Mock };
494+
aiChatInstance.getUserId.mockReturnValue(USER_ID);
495+
496+
const pushCall = (onSpy.mock.calls as any[][]).find((call) => call[0] === 'push');
497+
onSpy.mockRestore();
498+
499+
if (!pushCall?.[1]) {
500+
throw new Error('Push handler not found');
501+
}
502+
503+
return pushCall[1] as (changes: any[]) => void;
504+
};
505+
506+
it('should subscribe to store push event during init', () => {
507+
const onSpy = jest.spyOn(mockMessageStore, 'on');
508+
createAIAssistantView({ render: false });
509+
510+
expect(onSpy).toHaveBeenCalledWith('push', expect.any(Function));
511+
onSpy.mockRestore();
512+
});
513+
514+
it('should unsubscribe from previous push handler before subscribing', () => {
515+
const offSpy = jest.spyOn(mockMessageStore, 'off');
516+
const onSpy = jest.spyOn(mockMessageStore, 'on');
517+
createAIAssistantView();
518+
519+
const onPushCall = (onSpy.mock.calls as any[][]).find((call) => call[0] === 'push') as any[];
520+
const offPushCall = (offSpy.mock.calls as any[][]).find((call) => call[0] === 'push') as any[];
521+
522+
expect(offPushCall).toBeDefined();
523+
expect(onPushCall).toBeDefined();
524+
expect(offPushCall[1]).toBe(onPushCall[1]);
525+
526+
offSpy.mockRestore();
527+
onSpy.mockRestore();
528+
});
529+
530+
it('should call sendRequestToAI when user message is inserted via store push', () => {
531+
mockAIAssistantController.sendRequestToAI.mockReturnValue(Promise.resolve());
532+
const pushHandler = getPushHandler();
533+
534+
const userMessage = {
535+
id: 'user-msg-1',
536+
author: { id: USER_ID, name: 'User' },
537+
text: 'Sort by name',
538+
};
539+
540+
pushHandler([{ type: 'insert', data: userMessage }]);
541+
542+
expect(mockAIAssistantController.sendRequestToAI).toHaveBeenCalledTimes(1);
543+
expect(mockAIAssistantController.sendRequestToAI).toHaveBeenCalledWith(userMessage);
544+
});
545+
546+
it('should not call sendRequestToAI when AI message is inserted via store push', () => {
547+
const pushHandler = getPushHandler();
548+
549+
const aiMessage = {
550+
id: 'assistant-msg-1',
551+
author: { id: 'assistant' },
552+
text: 'Done',
553+
prompt: 'Sort by name',
554+
status: 'success',
555+
headerText: 'Sort',
556+
};
557+
558+
pushHandler([{ type: 'insert', data: aiMessage }]);
559+
560+
expect(mockAIAssistantController.sendRequestToAI).not.toHaveBeenCalled();
561+
});
562+
563+
it('should not call sendRequestToAI when message from another user is inserted via store push', () => {
564+
const pushHandler = getPushHandler();
565+
566+
const otherUserMessage = {
567+
id: 'other-msg-1',
568+
author: { id: 'other-user', name: 'Other' },
569+
text: 'Hello',
570+
};
571+
572+
pushHandler([{ type: 'insert', data: otherUserMessage }]);
573+
574+
expect(mockAIAssistantController.sendRequestToAI).not.toHaveBeenCalled();
575+
});
576+
577+
it('should not call sendRequestToAI when message is updated via store push', () => {
578+
const pushHandler = getPushHandler();
579+
580+
pushHandler([{
581+
type: 'update',
582+
key: 'msg-1',
583+
data: { text: 'updated' },
584+
}]);
585+
586+
expect(mockAIAssistantController.sendRequestToAI).not.toHaveBeenCalled();
587+
});
588+
589+
it('should not call sendRequestToAI when message is removed via store push', () => {
590+
const pushHandler = getPushHandler();
591+
592+
pushHandler([{ type: 'delete', key: 'msg-1' }]);
593+
594+
expect(mockAIAssistantController.sendRequestToAI).not.toHaveBeenCalled();
595+
});
596+
});
463597
});
464598

465599
describe('optionChanged', () => {

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isEnabledOption,
1414
isPopupOptions,
1515
isTitleOption,
16+
isUserMessage,
1617
} from '../utils';
1718

1819
describe('isAIMessage', () => {
@@ -43,6 +44,43 @@ describe('isAIMessage', () => {
4344
});
4445
});
4546

47+
describe('isUserMessage', () => {
48+
it('should return true when message author id matches userId', () => {
49+
const message = {
50+
author: { id: 'user-1', name: 'User' },
51+
text: 'request',
52+
} as Message;
53+
54+
expect(isUserMessage(message, 'user-1')).toBe(true);
55+
});
56+
57+
it('should return false when message author id does not match userId', () => {
58+
const message = {
59+
author: { id: 'user-1', name: 'User' },
60+
text: 'request',
61+
} as Message;
62+
63+
expect(isUserMessage(message, 'user-2')).toBe(false);
64+
});
65+
66+
it('should return false for AI message', () => {
67+
const message = {
68+
author: { id: AI_ASSISTANT_AUTHOR_ID },
69+
text: 'response',
70+
} as Message;
71+
72+
expect(isUserMessage(message, 'user-1')).toBe(false);
73+
});
74+
75+
it('should return false for message without author', () => {
76+
const message = {
77+
text: 'request',
78+
} as Message;
79+
80+
expect(isUserMessage(message, 'user-1')).toBe(false);
81+
});
82+
});
83+
4684
describe('isEnabledOption', () => {
4785
it('should return true for enabled option names', () => {
4886
expect(isEnabledOption('aiAssistant.enabled', true)).toBe(true);

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { ArrayStore } from '@js/common/data';
77
import Guid from '@js/core/guid';
88
import { captionize } from '@js/core/utils/inflector';
99
import { isFunction, isString } from '@js/core/utils/type';
10-
import type { DataSourceLike } from '@js/data/data_source';
1110
import type { Message } from '@js/ui/chat';
1211
import { fromPromise } from '@ts/core/utils/m_deferred';
1312

@@ -243,10 +242,8 @@ export class AIAssistantController extends Controller {
243242
this.aiAssistantIntegrationController.init();
244243
}
245244

246-
public getMessageDataSource(): DataSourceLike<Message> {
247-
return {
248-
store: this.messageStore,
249-
};
245+
public getMessageStore(): ArrayStore<Message, string> {
246+
return this.messageStore ?? new ArrayStore({ key: 'id' });
250247
}
251248

252249
public sendRequestToAI(message: Message | AIMessage): Promise<void> {

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { PositionConfig } from '@js/common/core/animation';
2+
import type { ArrayStore } from '@js/common/data';
23
import type { Callback } from '@js/core/utils/callbacks';
34
import { getHeight } from '@js/core/utils/size';
45
import type { Message, Properties as ChatProperties } from '@js/ui/chat';
@@ -10,10 +11,12 @@ import {
1011
isEnabledOption,
1112
isPopupOptions,
1213
isTitleOption,
14+
isUserMessage,
1315
} from '@ts/grids/grid_core/ai_assistant/utils';
1416
import type { ColumnHeadersView } from '@ts/grids/grid_core/column_headers/m_column_headers';
1517
import type { OptionChanged } from '@ts/grids/grid_core/m_types';
1618
import type { RowsView } from '@ts/grids/grid_core/views/m_rows_view';
19+
import type { DataChange } from '@ts/ui/collection/collection_widget.base';
1720

1821
import { AIChat } from '../ai_chat/ai_chat';
1922
import type { AIChatOptions } from '../ai_chat/types';
@@ -26,16 +29,25 @@ export class AIAssistantView extends View {
2629

2730
private aiAssistantController!: AIAssistantController;
2831

32+
private messageStore?: ArrayStore<Message, string>;
33+
2934
private columnHeadersView!: ColumnHeadersView;
3035

3136
private rowsView!: RowsView;
3237

38+
private handleMessageStorePushContext!: (changes: DataChange<Message, string>[]) => void;
39+
3340
public visibilityChanged?: Callback;
3441

3542
public init(): void {
3643
this.columnHeadersView = this.getView('columnHeadersView');
3744
this.rowsView = this.getView('rowsView');
3845
this.aiAssistantController = this.getController('aiAssistant');
46+
this.messageStore = this.aiAssistantController.getMessageStore();
47+
this.handleMessageStorePushContext = this.handleMessageStorePush.bind(this);
48+
49+
this.unsubscribeMessageStorePush();
50+
this.subscribeMessageStorePush();
3951
}
4052

4153
private getAIChatConfig(): AIChatOptions {
@@ -97,9 +109,27 @@ export class AIAssistantView extends View {
97109
});
98110
}
99111

112+
private handleMessageStorePush(changes: DataChange<Message, string>[]): void {
113+
const userId = this.aiChatInstance.getUserId();
114+
115+
changes.forEach(({ type, data }) => {
116+
if (type === 'insert' && data && isUserMessage(data, userId)) {
117+
this.executeRequest(data);
118+
}
119+
});
120+
}
121+
122+
private unsubscribeMessageStorePush(): void {
123+
this.messageStore?.off('push', this.handleMessageStorePushContext);
124+
}
125+
126+
private subscribeMessageStorePush(): void {
127+
this.messageStore?.on('push', this.handleMessageStorePushContext);
128+
}
129+
100130
private getAIChatOptions(): ChatProperties {
101131
return {
102-
dataSource: this.aiAssistantController.getMessageDataSource(),
132+
dataSource: this.messageStore,
103133
reloadOnChange: true,
104134
onMessageEntered: (e): void => {
105135
this.executeRequest(e.message);
@@ -116,6 +146,12 @@ export class AIAssistantView extends View {
116146
}
117147
}
118148

149+
public dispose(): void {
150+
this.unsubscribeMessageStorePush();
151+
this.messageStore = undefined;
152+
super.dispose();
153+
}
154+
119155
public optionChanged(args: OptionChanged): void {
120156
if (args.name === 'aiAssistant') {
121157
const enabledChanged = isEnabledOption(args.fullName, args.value);

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ export const isAIMessage = (
88
message: Message,
99
): message is AIMessage => message.author?.id === AI_ASSISTANT_AUTHOR_ID;
1010

11+
export const isUserMessage = (
12+
message: Message,
13+
userId: string,
14+
): boolean => message.author?.id === userId;
15+
1116
export const isEnabledOption = (optionName: string, value: unknown): boolean => optionName.startsWith('aiAssistant.enabled')
1217
|| (optionName === 'aiAssistant' && isObject(value) && 'enabled' in value);
1318

0 commit comments

Comments
 (0)