Skip to content

Commit

Permalink
Chat: data layer integration (#28114)
Browse files Browse the repository at this point in the history
  • Loading branch information
EugeniyKiyashko authored Oct 1, 2024
1 parent 6d2f7f0 commit 15339b2
Show file tree
Hide file tree
Showing 2 changed files with 291 additions and 6 deletions.
48 changes: 44 additions & 4 deletions packages/devextreme/js/__internal/ui/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import registerComponent from '@js/core/component_registrator';
import Guid from '@js/core/guid';
import type { dxElementWrapper } from '@js/core/renderer';
import $ from '@js/core/renderer';
import { isDefined } from '@js/core/utils/type';
import type { Options as DataSourceOptions } from '@js/data/data_source';
import DataHelperMixin from '@js/data_helper';
import type { Message, MessageSendEvent, Properties as ChatProperties } from '@js/ui/chat';
import type { OptionChanged } from '@ts/core/widget/types';
import Widget from '@ts/core/widget/widget';
Expand Down Expand Up @@ -45,9 +48,27 @@ class Chat extends Widget<Properties> {
_init(): void {
super._init();

// @ts-expect-error
this._initDataController();

// @ts-expect-error
this._refreshDataSource();

this._createMessageSendAction();
}

_dataSourceLoadErrorHandler(): void {
this.option('items', []);
}

_dataSourceChangedHandler(newItems: Message[]): void {
this.option('items', newItems.slice());
}

_dataSourceOptions(): DataSourceOptions {
return { paginate: false };
}

_initMarkup(): void {
$(this.element()).addClass(CHAT_CLASS);

Expand Down Expand Up @@ -183,10 +204,13 @@ class Chat extends Widget<Properties> {
break;
}
case 'items':
case 'dataSource':
this._messageList.option(name, value);
this._updateMessageBoxAria();
break;
case 'dataSource':
// @ts-expect-error
this._refreshDataSource();
break;
case 'onMessageSend':
this._createMessageSendAction();
break;
Expand All @@ -195,15 +219,31 @@ class Chat extends Widget<Properties> {
}
}

renderMessage(message: Message = {}): void {
_insertNewItem(item: Message): void {
const { items } = this.option();

const newItems = [...items ?? [], message];

const newItems = [...items ?? [], item];
this.option('items', newItems);
}

renderMessage(message: Message = {}): void {
// @ts-expect-error
const dataSource = this.getDataSource();

if (!isDefined(dataSource)) {
this._insertNewItem(message);
return;
}

dataSource.store().insert(message).done(() => {
this._insertNewItem(message);
});
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Chat as any).include(DataHelperMixin);

registerComponent('dxChat', Chat);

export default Chat;
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import Chat from 'ui/chat';
import MessageList from '__internal/ui/chat/messagelist';
import MessageBox from '__internal/ui/chat/messagebox';
import keyboardMock from '../../../helpers/keyboardMock.js';
import { DataSource } from 'data/data_source/data_source';
import CustomStore from 'data/custom_store';

import { isRenderer } from 'core/utils/type';

import config from 'core/config';
import ArrayStore from 'data/array_store';

const CHAT_HEADER_TEXT_CLASS = 'dx-chat-header-text';
const CHAT_MESSAGEGROUP_CLASS = 'dx-chat-messagegroup';
Expand All @@ -16,6 +19,7 @@ const CHAT_MESSAGEBUBBLE_CLASS = 'dx-chat-messagebubble';
const CHAT_MESSAGEBOX_CLASS = 'dx-chat-messagebox';
const CHAT_MESSAGEBOX_BUTTON_CLASS = 'dx-chat-messagebox-button';
const CHAT_MESSAGEBOX_TEXTAREA_CLASS = 'dx-chat-messagebox-textarea';
const CHAT_MESSAGELIST_EMPTY_VIEW_CLASS = 'dx-chat-messagelist-empty-view';

const TEXTEDITOR_INPUT_CLASS = 'dx-texteditor-input';

Expand Down Expand Up @@ -70,6 +74,14 @@ const moduleConfig = {
init(options);
};

this.getEmptyView = () => {
return this.$element.find(`.${CHAT_MESSAGELIST_EMPTY_VIEW_CLASS}`);
};

this.getBubbles = () => {
return this.$element.find(`.${CHAT_MESSAGEBUBBLE_CLASS}`);
};

init();
}
};
Expand Down Expand Up @@ -233,7 +245,7 @@ QUnit.module('Chat', moduleConfig, () => {

this.$sendButton.trigger('dxclick');

const $bubbles = this.$element.find(`.${CHAT_MESSAGEBUBBLE_CLASS}`);
const $bubbles = this.getBubbles();
const bubble = $bubbles[$bubbles.length - 1];

assert.strictEqual($(bubble).text(), text);
Expand Down Expand Up @@ -342,7 +354,7 @@ QUnit.module('Chat', moduleConfig, () => {

this.instance.renderMessage(newMessage);

const $bubbles = this.$element.find(`.${CHAT_MESSAGEBUBBLE_CLASS}`);
const $bubbles = this.getBubbles();

assert.strictEqual($bubbles.length, 4, 'false');
assert.strictEqual($bubbles.last().text(), text ? text : '', 'text value is correct');
Expand Down Expand Up @@ -395,6 +407,239 @@ QUnit.module('Chat', moduleConfig, () => {

assert.strictEqual(activeElement, this.$input.get(0));
});

QUnit.test('getDataSource() should return null when dataSource is not defined', function(assert) {
this.reinit({
items: []
});

assert.strictEqual(this.instance.getDataSource(), null);
});

QUnit.test('getDataSource() should return the dataSource object when dataSource is passed', function(assert) {
this.reinit({
dataSource: [{ text: 'message_text' }]
});

assert.ok(this.instance.getDataSource() instanceof DataSource);
});
});

QUnit.module('Data Layer Integration', moduleConfig, () => {
QUnit.test('Should render empty view container if dataSource is empty', function(assert) {
this.reinit({
dataSource: {
store: new ArrayStore([])
}
});

assert.strictEqual(this.$element.find(`.${CHAT_MESSAGELIST_EMPTY_VIEW_CLASS}`).length, 1);
});

QUnit.test('Should remove or render empty view container after dataSource is updated at runtime', function(assert) {
this.instance.option('dataSource', {
store: new ArrayStore([{}]),
});

assert.strictEqual(this.$element.find(`.${CHAT_MESSAGELIST_EMPTY_VIEW_CLASS}`).length, 0);

this.instance.option('dataSource', {
store: new ArrayStore([])
});

assert.strictEqual(this.$element.find(`.${CHAT_MESSAGELIST_EMPTY_VIEW_CLASS}`).length, 1);
});

QUnit.test('Items should synchronize with dataSource when declared as an array', function(assert) {
const messages = [{ text: 'message_1' }, { text: 'message_2' }];
this.reinit({
dataSource: messages,
});

assert.deepEqual(this.instance.option('items'), messages);
});

QUnit.test('items option should be updated after calling renderMessage(newMessage)', function(assert) {
const messages = [{ text: 'message_1' }, { text: 'message_2' }];
this.reinit({
items: messages,
});

const newMessage = { text: 'message_3' };
this.instance.renderMessage(newMessage);

const expectedData = [...messages, newMessage];
assert.deepEqual(this.instance.option('items'), expectedData, 'items option should contain all messages including the new one');
assert.deepEqual(this.instance.option('dataSource'), null, 'dataSource option should remain null');
});

QUnit.test('dataSource option should be updated after calling renderMessage(newMessage)', function(assert) {
const messages = [{ text: 'message_1' }, { text: 'message_2' }];
this.reinit({
dataSource: [...messages],
});

const newMessage = { text: 'message_3' };
this.instance.renderMessage(newMessage);

const expectedData = [...messages, newMessage];
assert.deepEqual(this.instance.option('items'), expectedData, 'items option should contain all messages including the new one');
assert.deepEqual(this.instance.option('dataSource'), expectedData, 'dataSource option should contain all messages including the new one');
});

QUnit.test('Items should synchronize with DataSource store', function(assert) {
const messages = [{ text: 'message_1' }, { text: 'message_2' }];

this.reinit({
dataSource: new DataSource({
store: new ArrayStore({
data: messages,
}),
})
});

assert.deepEqual(this.instance.option('items'), messages);
});

QUnit.test('Items should synchronize with DataSource store after adding new message', function(assert) {
const messages = [{ text: 'message_1' }, { text: 'message_2' }];

this.reinit({
dataSource: new DataSource({
store: new ArrayStore({
data: [...messages],
}),
})
});

const newMessage = { text: 'message_3' };
this.instance.renderMessage(newMessage);

const expectedData = [...messages, newMessage];

assert.deepEqual(this.instance.option('items'), expectedData);
});

QUnit.test('Items should synchronize with dataSource when declared as a store', function(assert) {
const messages = [{ text: 'message_1' }, { text: 'message_2' }];
this.reinit({
dataSource: new ArrayStore(messages),
});

assert.deepEqual(this.instance.option('items'), messages);
});

QUnit.test('DataSource pagination is false by default', function(assert) {
this.instance.option('dataSource', {
store: new ArrayStore([{}]),
});

assert.strictEqual(this.instance.getDataSource().paginate(), false);
});

QUnit.test('should handle dataSource loading error', function(assert) {
const deferred = $.Deferred();
const messages = [{ text: 'message_1' }, { text: 'message_2' }];
this.reinit({
dataSource: messages
});

this.instance.option({
dataSource: {
load() {
return deferred.promise();
}
},
});

deferred.reject();

assert.strictEqual(this.$element.find(`.${CHAT_MESSAGELIST_EMPTY_VIEW_CLASS}`).length, 1, 'empty view container was rendered on loading failure');
});

QUnit.test('should render all messages correctly when using an asynchronous data source', function(assert) {
const clock = sinon.useFakeTimers();

try {
const messages = [{ text: 'message_1' }, { text: 'message_2' }];
const timeout = 1000;

const store = new CustomStore({
load: function() {
const d = $.Deferred();
setTimeout(function() {
d.resolve(messages);
}, timeout);
return d.promise();
},
});

this.reinit({
dataSource: store,
});

assert.strictEqual(this.getEmptyView().length, 1, 'empty messagelist view should be rendered');
assert.strictEqual(this.getBubbles().length, 0, 'there should be no message bubbles rendered');

clock.tick(timeout / 2);

assert.strictEqual(this.getEmptyView().length, 1, 'empty messagelist view should still be visible while data is loading');
assert.strictEqual(this.getBubbles().length, 0, 'should still be no message bubbles rendered while data is loading');

clock.tick(timeout / 2);

assert.strictEqual(this.getEmptyView().length, 0, 'empty messagelist view should not be visible when data is loaded');
assert.strictEqual(this.getBubbles().length, 2, 'message bubbles should be rendered when data is loaded');

} finally {
clock.restore();
}
});

QUnit.test('new message should be rendered when using an asynchronous custom store', function(assert) {
const clock = sinon.useFakeTimers();

try {
const messages = [{ text: 'message_1' }, { text: 'message_2' }];
const timeout = 1000;

const store = new CustomStore({
load: function() {
const d = $.Deferred();
setTimeout(function() {
d.resolve(messages);
}, timeout);
return d.promise();
},
insert: function(values) {
const d = $.Deferred();

setTimeout(() => {
messages.push(values);
d.resolve(values);
}, timeout);

return d.promise();
},
});

this.reinit({
dataSource: store,
});

clock.tick(timeout);

const newMessage = { text: 'message_3' };
this.instance.renderMessage(newMessage);

clock.tick(timeout * 2);

assert.deepEqual(this.instance.option('items'), messages, 'items option should contain all messages including the new one');
assert.strictEqual(this.getBubbles().length, 3, 'new message should be rendered in list');
} finally {
clock.restore();
}
});
});
});

Expand Down

0 comments on commit 15339b2

Please sign in to comment.