Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,9 @@ const createController = (
return controller;
};

const getStore = (controller: AIAssistantController): ArrayStore<Message, string> => {
const dataSource = controller.getMessageDataSource() as { store: ArrayStore<Message, string> };
return dataSource.store;
};
const getStore = (
controller: AIAssistantController,
): ArrayStore<Message, string> => controller.getMessageStore();

describe('AIAssistantController', () => {
beforeEach(() => {
Expand Down Expand Up @@ -91,14 +90,12 @@ describe('AIAssistantController', () => {
);
});

describe('getMessageDataSource', () => {
it('should return dataSource with store', () => {
describe('getMessageStore', () => {
it('should return message store', () => {
const controller = createController();
const dataSource = controller.getMessageDataSource() as {
store: ArrayStore<Message, string>;
};
const store = controller.getMessageStore();

expect(dataSource.store).toBeDefined();
expect(store).toBeDefined();
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ const createComponentMock = jest.fn((
options: any,
): any => new Widget(el, options));

const mockMessageDataSource = { store: new ArrayStore({ key: 'id' }), reshapeOnPush: true };
const mockMessageStore = new ArrayStore({ key: 'id' });
const mockAIAssistantController = {
getMessageDataSource: jest.fn().mockReturnValue(mockMessageDataSource),
getMessageStore: jest.fn().mockReturnValue(mockMessageStore),
sendRequestToAI: jest.fn(),
abortRequest: jest.fn(),
};
Expand Down Expand Up @@ -110,6 +110,7 @@ const beforeTest = (): void => {
};

const afterTest = (): void => {
mockMessageStore.off('push');
document.body.innerHTML = '';
fx.off = false;
jest.useRealTimers();
Expand Down Expand Up @@ -159,9 +160,9 @@ describe('AIAssistantView', () => {

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

expect(mockAIAssistantController.getMessageDataSource).toHaveBeenCalledTimes(1);
expect(mockAIAssistantController.getMessageStore).toHaveBeenCalledTimes(1);
expect(aiChatConfig.chatOptions).toEqual(expect.objectContaining({
dataSource: mockMessageDataSource,
dataSource: mockMessageStore,
reloadOnChange: true,
onMessageEntered: expect.any(Function),
}));
Expand Down Expand Up @@ -233,6 +234,26 @@ describe('AIAssistantView', () => {
});
});

describe('dispose', () => {
it('should unsubscribe from store push event', () => {
const onSpy = jest.spyOn(mockMessageStore, 'on');
const { aiAssistantView } = createAIAssistantView();
const offSpy = jest.spyOn(mockMessageStore, 'off');

aiAssistantView.dispose();

const onPushCall = (onSpy.mock.calls as any[][]).find((call) => call[0] === 'push');
const offPushCall = (offSpy.mock.calls as any[][]).find((call) => call[0] === 'push');

expect(onPushCall).toBeDefined();
expect(offPushCall).toBeDefined();
expect((offPushCall as any[])[1]).toBe((onPushCall as any[])[1]);

offSpy.mockRestore();
onSpy.mockRestore();
});
});

describe('isShown', () => {
it('should delegate to AIChat isShown method', () => {
const { aiAssistantView } = createAIAssistantView();
Expand Down Expand Up @@ -460,6 +481,119 @@ describe('AIAssistantView', () => {
expect(aiChatInstance.setDisabled).toHaveBeenLastCalledWith(false);
});
});

describe('handleMessageStorePush', () => {
const USER_ID = 'user';

const getPushHandler = (): (changes: any[]) => void => {
const onSpy = jest.spyOn(mockMessageStore, 'on');
createAIAssistantView();

const aiChatInstance = (AIChat as jest.Mock)
.mock.results[0].value as { getUserId: jest.Mock };
aiChatInstance.getUserId.mockReturnValue(USER_ID);

const pushCall = (onSpy.mock.calls as any[][]).find((call) => call[0] === 'push');
onSpy.mockRestore();

if (!pushCall?.[1]) {
throw new Error('Push handler not found');
}

return pushCall[1] as (changes: any[]) => void;
};

it('should subscribe to store push event during init', () => {
const onSpy = jest.spyOn(mockMessageStore, 'on');
createAIAssistantView({ render: false });

expect(onSpy).toHaveBeenCalledWith('push', expect.any(Function));
onSpy.mockRestore();
});

it('should unsubscribe from previous push handler before subscribing', () => {
const offSpy = jest.spyOn(mockMessageStore, 'off');
const onSpy = jest.spyOn(mockMessageStore, 'on');
createAIAssistantView();

const onPushCall = (onSpy.mock.calls as any[][]).find((call) => call[0] === 'push') as any[];
const offPushCall = (offSpy.mock.calls as any[][]).find((call) => call[0] === 'push') as any[];

expect(offPushCall).toBeDefined();
expect(onPushCall).toBeDefined();
expect(offPushCall[1]).toBe(onPushCall[1]);

offSpy.mockRestore();
onSpy.mockRestore();
});

it('should call sendRequestToAI when user message is inserted via store push', () => {
mockAIAssistantController.sendRequestToAI.mockReturnValue(Promise.resolve());
const pushHandler = getPushHandler();

const userMessage = {
id: 'user-msg-1',
author: { id: USER_ID, name: 'User' },
text: 'Sort by name',
};

pushHandler([{ type: 'insert', data: userMessage }]);

expect(mockAIAssistantController.sendRequestToAI).toHaveBeenCalledTimes(1);
expect(mockAIAssistantController.sendRequestToAI).toHaveBeenCalledWith(userMessage);
});

it('should not call sendRequestToAI when AI message is inserted via store push', () => {
const pushHandler = getPushHandler();

const aiMessage = {
id: 'assistant-msg-1',
author: { id: 'assistant' },
text: 'Done',
prompt: 'Sort by name',
status: 'success',
headerText: 'Sort',
};

pushHandler([{ type: 'insert', data: aiMessage }]);

expect(mockAIAssistantController.sendRequestToAI).not.toHaveBeenCalled();
});

it('should not call sendRequestToAI when message from another user is inserted via store push', () => {
const pushHandler = getPushHandler();

const otherUserMessage = {
id: 'other-msg-1',
author: { id: 'other-user', name: 'Other' },
text: 'Hello',
};

pushHandler([{ type: 'insert', data: otherUserMessage }]);

expect(mockAIAssistantController.sendRequestToAI).not.toHaveBeenCalled();
});

it('should not call sendRequestToAI when message is updated via store push', () => {
const pushHandler = getPushHandler();

pushHandler([{
type: 'update',
key: 'msg-1',
data: { text: 'updated' },
}]);

expect(mockAIAssistantController.sendRequestToAI).not.toHaveBeenCalled();
});

it('should not call sendRequestToAI when message is removed via store push', () => {
const pushHandler = getPushHandler();

pushHandler([{ type: 'delete', key: 'msg-1' }]);

expect(mockAIAssistantController.sendRequestToAI).not.toHaveBeenCalled();
});
});
});

describe('optionChanged', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isEnabledOption,
isPopupOptions,
isTitleOption,
isUserMessage,
} from '../utils';

describe('isAIMessage', () => {
Expand Down Expand Up @@ -43,6 +44,43 @@ describe('isAIMessage', () => {
});
});

describe('isUserMessage', () => {
it('should return true when message author id matches userId', () => {
const message = {
author: { id: 'user-1', name: 'User' },
text: 'request',
} as Message;

expect(isUserMessage(message, 'user-1')).toBe(true);
});

it('should return false when message author id does not match userId', () => {
const message = {
author: { id: 'user-1', name: 'User' },
text: 'request',
} as Message;

expect(isUserMessage(message, 'user-2')).toBe(false);
});

it('should return false for AI message', () => {
const message = {
author: { id: AI_ASSISTANT_AUTHOR_ID },
text: 'response',
} as Message;

expect(isUserMessage(message, 'user-1')).toBe(false);
});

it('should return false for message without author', () => {
const message = {
text: 'request',
} as Message;

expect(isUserMessage(message, 'user-1')).toBe(false);
});
});

describe('isEnabledOption', () => {
it('should return true for enabled option names', () => {
expect(isEnabledOption('aiAssistant.enabled', true)).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { ArrayStore } from '@js/common/data';
import Guid from '@js/core/guid';
import { captionize } from '@js/core/utils/inflector';
import { isFunction, isString } from '@js/core/utils/type';
import type { DataSourceLike } from '@js/data/data_source';
import type { Message } from '@js/ui/chat';
import { fromPromise } from '@ts/core/utils/m_deferred';

Expand Down Expand Up @@ -243,10 +242,8 @@ export class AIAssistantController extends Controller {
this.aiAssistantIntegrationController.init();
}

public getMessageDataSource(): DataSourceLike<Message> {
return {
store: this.messageStore,
};
public getMessageStore(): ArrayStore<Message, string> {
return this.messageStore ?? new ArrayStore({ key: 'id' });
}
Comment thread
Alyar666 marked this conversation as resolved.

public sendRequestToAI(message: Message | AIMessage): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PositionConfig } from '@js/common/core/animation';
import type { ArrayStore } from '@js/common/data';
import type { Callback } from '@js/core/utils/callbacks';
import { getHeight } from '@js/core/utils/size';
import type { Message, Properties as ChatProperties } from '@js/ui/chat';
Expand All @@ -10,10 +11,12 @@ import {
isEnabledOption,
isPopupOptions,
isTitleOption,
isUserMessage,
} from '@ts/grids/grid_core/ai_assistant/utils';
import type { ColumnHeadersView } from '@ts/grids/grid_core/column_headers/m_column_headers';
import type { OptionChanged } from '@ts/grids/grid_core/m_types';
import type { RowsView } from '@ts/grids/grid_core/views/m_rows_view';
import type { DataChange } from '@ts/ui/collection/collection_widget.base';

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

private aiAssistantController!: AIAssistantController;

private messageStore?: ArrayStore<Message, string>;

private columnHeadersView!: ColumnHeadersView;

private rowsView!: RowsView;

private handleMessageStorePushContext!: (changes: DataChange<Message, string>[]) => void;

public visibilityChanged?: Callback;

public init(): void {
this.columnHeadersView = this.getView('columnHeadersView');
this.rowsView = this.getView('rowsView');
this.aiAssistantController = this.getController('aiAssistant');
this.messageStore = this.aiAssistantController.getMessageStore();
this.handleMessageStorePushContext = this.handleMessageStorePush.bind(this);

this.unsubscribeMessageStorePush();
this.subscribeMessageStorePush();
}

private getAIChatConfig(): AIChatOptions {
Expand Down Expand Up @@ -97,9 +109,27 @@ export class AIAssistantView extends View {
});
}

private handleMessageStorePush(changes: DataChange<Message, string>[]): void {
const userId = this.aiChatInstance.getUserId();

changes.forEach(({ type, data }) => {
if (type === 'insert' && data && isUserMessage(data, userId)) {
this.executeRequest(data);
}
});
Comment thread
Alyar666 marked this conversation as resolved.
}

private unsubscribeMessageStorePush(): void {
this.messageStore?.off('push', this.handleMessageStorePushContext);
}

private subscribeMessageStorePush(): void {
this.messageStore?.on('push', this.handleMessageStorePushContext);
}

private getAIChatOptions(): ChatProperties {
return {
dataSource: this.aiAssistantController.getMessageDataSource(),
dataSource: this.messageStore,
reloadOnChange: true,
onMessageEntered: (e): void => {
this.executeRequest(e.message);
Expand All @@ -116,6 +146,12 @@ export class AIAssistantView extends View {
}
}

public dispose(): void {
this.unsubscribeMessageStorePush();
this.messageStore = undefined;
super.dispose();
}

public optionChanged(args: OptionChanged): void {
if (args.name === 'aiAssistant') {
const enabledChanged = isEnabledOption(args.fullName, args.value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ export const isAIMessage = (
message: Message,
): message is AIMessage => message.author?.id === AI_ASSISTANT_AUTHOR_ID;

export const isUserMessage = (
message: Message,
userId: string,
): boolean => message.author?.id === userId;

export const isEnabledOption = (optionName: string, value: unknown): boolean => optionName.startsWith('aiAssistant.enabled')
|| (optionName === 'aiAssistant' && isObject(value) && 'enabled' in value);

Expand Down
Loading
Loading