diff --git a/packages/devextreme/js/__internal/ui/chat/chat.ts b/packages/devextreme/js/__internal/ui/chat/chat.ts index 38d91283b981..ecedebc3cdaa 100644 --- a/packages/devextreme/js/__internal/ui/chat/chat.ts +++ b/packages/devextreme/js/__internal/ui/chat/chat.ts @@ -5,11 +5,13 @@ 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 { NativeEventInfo } from '@js/events'; import messageLocalization from '@js/localization/message'; import type { Message, MessageSendEvent, Properties as ChatProperties, + User, } from '@js/ui/chat'; import type { OptionChanged } from '@ts/core/widget/types'; import Widget from '@ts/core/widget/widget'; @@ -19,6 +21,7 @@ import ChatHeader from './header'; import type { MessageSendEvent as MessageBoxMessageSendEvent, Properties as MessageBoxProperties, + TypingStartEvent as MessageBoxTypingStartEvent, } from './messagebox'; import MessageBox from './messagebox'; import MessageList from './messagelist'; @@ -26,9 +29,14 @@ import MessageList from './messagelist'; const CHAT_CLASS = 'dx-chat'; const TEXTEDITOR_INPUT_CLASS = 'dx-texteditor-input'; +type TypingStartEvent = NativeEventInfo & { user?: User }; +type TypingEndEvent = NativeEventInfo & { user?: User }; + type Properties = ChatProperties & { title: string; showDayHeaders: boolean; + onTypingStart?: ((e: TypingStartEvent) => void); + onTypingEnd?: ((e: TypingEndEvent) => void); }; class Chat extends Widget { @@ -42,19 +50,25 @@ class Chat extends Widget { _messageSendAction?: (e: Partial) => void; + _typingStartAction?: (e: Partial) => void; + + _typingEndAction?: (e: Partial) => void; + _getDefaultOptions(): Properties { return { ...super._getDefaultOptions(), + title: '', + showDayHeaders: true, activeStateEnabled: true, focusStateEnabled: true, hoverStateEnabled: true, - title: '', items: [], dataSource: null, user: { id: new Guid().toString() }, - onMessageSend: undefined, - showDayHeaders: true, errors: [], + onMessageSend: undefined, + onTypingStart: undefined, + onTypingEnd: undefined, }; } @@ -63,11 +77,12 @@ class Chat extends Widget { // @ts-expect-error this._initDataController(); - // @ts-expect-error this._refreshDataSource(); this._createMessageSendAction(); + this._createTypingStartAction(); + this._createTypingEndAction(); } _dataSourceLoadErrorHandler(): void { @@ -161,6 +176,12 @@ class Chat extends Widget { onMessageSend: (e) => { this._messageSendHandler(e); }, + onTypingStart: (e) => { + this._typingStartHandler(e); + }, + onTypingEnd: () => { + this._typingEndHandler(); + }, }; this._messageBox = this._createComponent($messageBox, MessageBox, configuration); @@ -184,7 +205,21 @@ class Chat extends Widget { _createMessageSendAction(): void { this._messageSendAction = this._createActionByOption( 'onMessageSend', - { excludeValidators: ['disabled', 'readOnly'] }, + { excludeValidators: ['disabled'] }, + ); + } + + _createTypingStartAction(): void { + this._typingStartAction = this._createActionByOption( + 'onTypingStart', + { excludeValidators: ['disabled'] }, + ); + } + + _createTypingEndAction(): void { + this._typingEndAction = this._createActionByOption( + 'onTypingEnd', + { excludeValidators: ['disabled'] }, ); } @@ -201,6 +236,19 @@ class Chat extends Widget { this._messageSendAction?.({ message, event }); } + _typingStartHandler(e: MessageBoxTypingStartEvent): void { + const { event } = e; + const { user } = this.option(); + + this._typingStartAction?.({ user, event }); + } + + _typingEndHandler(): void { + const { user } = this.option(); + + this._typingEndAction?.({ user }); + } + _focusTarget(): dxElementWrapper { const $input = $(this.element()).find(`.${TEXTEDITOR_INPUT_CLASS}`); @@ -249,6 +297,12 @@ class Chat extends Widget { case 'onMessageSend': this._createMessageSendAction(); break; + case 'onTypingStart': + this._createTypingStartAction(); + break; + case 'onTypingEnd': + this._createTypingEndAction(); + break; case 'showDayHeaders': this._messageList.option(name, value); break; diff --git a/packages/devextreme/js/__internal/ui/chat/messagebox.ts b/packages/devextreme/js/__internal/ui/chat/messagebox.ts index 2f030e6b7b08..f6e292abac77 100644 --- a/packages/devextreme/js/__internal/ui/chat/messagebox.ts +++ b/packages/devextreme/js/__internal/ui/chat/messagebox.ts @@ -7,7 +7,7 @@ import type { Properties as DOMComponentProperties } from '@ts/core/widget/dom_c import DOMComponent from '@ts/core/widget/dom_component'; import type { OptionChanged } from '@ts/core/widget/types'; -import type { EnterKeyEvent } from '../../../ui/text_area'; +import type { EnterKeyEvent, InputEvent } from '../../../ui/text_area'; import type dxTextArea from '../../../ui/text_area'; import TextArea from '../m_text_area'; @@ -15,18 +15,26 @@ const CHAT_MESSAGEBOX_CLASS = 'dx-chat-messagebox'; const CHAT_MESSAGEBOX_TEXTAREA_CLASS = 'dx-chat-messagebox-textarea'; const CHAT_MESSAGEBOX_BUTTON_CLASS = 'dx-chat-messagebox-button'; +export const TYPING_END_DELAY = 2000; + export type MessageSendEvent = NativeEventInfo & { text?: string }; -export interface Properties extends DOMComponentProperties { - onMessageSend?: (e: MessageSendEvent) => void; +export type TypingStartEvent = NativeEventInfo; +export interface Properties extends DOMComponentProperties { activeStateEnabled?: boolean; focusStateEnabled?: boolean; hoverStateEnabled?: boolean; + + onMessageSend?: (e: MessageSendEvent) => void; + + onTypingStart?: (e: TypingStartEvent) => void; + + onTypingEnd?: (e: NativeEventInfo) => void; } class MessageBox extends DOMComponent { @@ -36,13 +44,21 @@ class MessageBox extends DOMComponent { _messageSendAction?: (e: Partial) => void; + _typingStartAction?: (e: Partial) => void; + + _typingEndAction?: () => void; + + _typingEndTimeoutId?: ReturnType | undefined; + _getDefaultOptions(): Properties { return { ...super._getDefaultOptions(), - onMessageSend: undefined, activeStateEnabled: true, focusStateEnabled: true, hoverStateEnabled: true, + onMessageSend: undefined, + onTypingStart: undefined, + onTypingEnd: undefined, }; } @@ -50,6 +66,8 @@ class MessageBox extends DOMComponent { super._init(); this._createMessageSendAction(); + this._createTypingStartAction(); + this._createTypingEndAction(); } _initMarkup(): void { @@ -81,10 +99,13 @@ class MessageBox extends DOMComponent { autoResizeEnabled: true, valueChangeEvent: 'input', maxHeight: '8em', - onInput: (): void => { + onInput: (e: InputEvent): void => { const shouldButtonBeDisabled = !this._isValuableTextEntered(); this._toggleButtonDisableState(shouldButtonBeDisabled); + + this._triggerTypingStartAction(e); + this._updateTypingEndTimeout(); }, onEnterKey: (e: EnterKeyEvent): void => { if (!e.event?.shiftKey) { @@ -129,15 +150,54 @@ class MessageBox extends DOMComponent { _createMessageSendAction(): void { this._messageSendAction = this._createActionByOption( 'onMessageSend', - { excludeValidators: ['disabled', 'readOnly'] }, + { excludeValidators: ['disabled'] }, + ); + } + + _createTypingStartAction(): void { + this._typingStartAction = this._createActionByOption( + 'onTypingStart', + { excludeValidators: ['disabled'] }, + ); + } + + _createTypingEndAction(): void { + this._typingEndAction = this._createActionByOption( + 'onTypingEnd', + { excludeValidators: ['disabled'] }, ); } + _triggerTypingStartAction(e: InputEvent): void { + if (!this._typingEndTimeoutId) { + this._typingStartAction?.({ event: e.event }); + } + } + + _updateTypingEndTimeout(): void { + clearTimeout(this._typingEndTimeoutId); + + this._typingEndTimeoutId = setTimeout(() => { + this._typingEndAction?.(); + + this._clearTypingEndTimeout(); + }, TYPING_END_DELAY); + } + + _clearTypingEndTimeout(): void { + clearTimeout(this._typingEndTimeoutId); + + this._typingEndTimeoutId = undefined; + } + _sendHandler(e: ClickEvent | EnterKeyEvent): void { if (!this._isValuableTextEntered()) { return; } + this._clearTypingEndTimeout(); + this._typingEndAction?.(); + const { text } = this._textArea.option(); this._textArea.reset(); @@ -170,12 +230,27 @@ class MessageBox extends DOMComponent { } case 'onMessageSend': this._createMessageSendAction(); + + break; + case 'onTypingStart': + this._createTypingStartAction(); + + break; + case 'onTypingEnd': + this._createTypingEndAction(); + break; default: super._optionChanged(args); } } + _clean(): void { + this._clearTypingEndTimeout(); + + super._clean(); + } + updateInputAria(emptyViewId: string | null): void { this._textArea.option({ inputAttr: { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.tests.js index be8d019f1f78..a163f2147eff 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/chat.tests.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import Chat from 'ui/chat'; import MessageList from '__internal/ui/chat/messagelist'; import ErrorList from '__internal/ui/chat/errorlist'; -import MessageBox from '__internal/ui/chat/messagebox'; +import MessageBox, { TYPING_END_DELAY } 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'; @@ -339,6 +339,88 @@ QUnit.module('Chat', () => { this.$sendButton.trigger('dxclick'); }); }); + + QUnit.module('onTypingStart', moduleConfig, () => { + QUnit.test('should be called with correct arguments', function(assert) { + assert.expect(5); + + const currentUser = { id: 1 }; + + this.reinit({ + user: currentUser, + onTypingStart: ({ component, element, event, user }) => { + assert.strictEqual(component, this.instance, 'component field is correct'); + assert.strictEqual(isRenderer(element), !!config().useJQuery, 'element is correct'); + assert.strictEqual($(element).is(this.$element), true, 'element field is correct'); + assert.strictEqual(event.type, 'input', 'e.event.type is correct'); + assert.deepEqual(user, currentUser, 'user field is correct'); + }, + }); + + keyboardMock(this.$input) + .focus() + .type('n'); + }); + + QUnit.test('should be possible to change at runtime', function(assert) { + const onTypingStart = sinon.spy(); + + this.instance.option({ onTypingStart }); + + keyboardMock(this.$input) + .focus() + .type('n'); + + assert.strictEqual(onTypingStart.callCount, 1); + }); + }); + + QUnit.module('onTypingEnd', { + beforeEach: function() { + this.clock = sinon.useFakeTimers(); + + moduleConfig.beforeEach.apply(this, arguments); + }, + afterEach: function() { + this.clock.restore(); + } + }, () => { + QUnit.test('should be called with correct arguments', function(assert) { + assert.expect(4); + + const currentUser = { id: 1 }; + + this.reinit({ + user: currentUser, + onTypingEnd: ({ component, element, user }) => { + assert.strictEqual(component, this.instance, 'component field is correct'); + assert.strictEqual(isRenderer(element), !!config().useJQuery, 'element is correct'); + assert.strictEqual($(element).is(this.$element), true, 'element field is correct'); + assert.deepEqual(user, currentUser, 'user field is correct'); + }, + }); + + keyboardMock(this.$input) + .focus() + .type('n'); + + this.clock.tick(TYPING_END_DELAY); + }); + + QUnit.test('should be possible to change at runtime', function(assert) { + const onTypingEnd = sinon.spy(); + + this.instance.option({ onTypingEnd }); + + keyboardMock(this.$input) + .focus() + .type('n'); + + this.clock.tick(TYPING_END_DELAY); + + assert.strictEqual(onTypingEnd.callCount, 1); + }); + }); }); QUnit.module('renderMessage', moduleConfig, () => { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBox.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBox.tests.js index be1cbf03efe0..fbae23055004 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBox.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageBox.tests.js @@ -3,7 +3,7 @@ import keyboardMock from '../../../helpers/keyboardMock.js'; import { isRenderer } from 'core/utils/type'; import config from 'core/config'; -import MessageBox from '__internal/ui/chat/messagebox'; +import MessageBox, { TYPING_END_DELAY } from '__internal/ui/chat/messagebox'; import TextArea from '__internal/ui/m_text_area'; import Button from 'ui/button'; @@ -36,6 +36,7 @@ const moduleConfig = { init(); } }; + QUnit.module('MessageBox', moduleConfig, () => { QUnit.module('Render', () => { QUnit.test('should be initialized with correct type', function(assert) { @@ -293,6 +294,259 @@ QUnit.module('MessageBox', moduleConfig, () => { }); }); + QUnit.module('onTypingStart event', { + beforeEach: function() { + this.clock = sinon.useFakeTimers(); + + moduleConfig.beforeEach.apply(this, arguments); + }, + afterEach: function() { + this.clock.restore(); + } + }, () => { + QUnit.test('should be triggered once if a character is entered in the input', function(assert) { + const onTypingStartStub = sinon.stub(); + + this.reinit({ onTypingStart: onTypingStartStub }); + + keyboardMock(this.$input) + .focus() + .type('n'); + + assert.strictEqual(onTypingStartStub.callCount, 1); + }); + + QUnit.test('should be possible to update it at runtime', function(assert) { + const onTypingStartStub = sinon.stub(); + + this.instance.option('onTypingStart', onTypingStartStub); + + keyboardMock(this.$input) + .focus() + .type('n'); + + assert.strictEqual(onTypingStartStub.callCount, 1); + }); + + QUnit.test('should be possible to update it at runtime if init value is function', function(assert) { + this.reinit({ onTypingStart: () => {} }); + + const onTypingStartStub = sinon.stub(); + + this.instance.option('onTypingStart', onTypingStartStub); + + keyboardMock(this.$input) + .focus() + .type('n'); + + assert.strictEqual(onTypingStartStub.callCount, 1); + }); + + [' ', '\n'].forEach(value => { + QUnit.test(`should be triggered if an empty character is entered in the input, value is '${value}'`, function(assert) { + const onTypingStartStub = sinon.stub(); + + this.reinit({ onTypingStart: onTypingStartStub }); + + keyboardMock(this.$input) + .focus() + .type(value); + + assert.strictEqual(onTypingStartStub.callCount, 1); + }); + }); + + ['n', 'no'].forEach(value => { + QUnit.test(`should be triggered if backspace was pressed after ${value.length} character(s) was entered`, function(assert) { + const onTypingStartStub = sinon.stub(); + + this.reinit({ onTypingStart: onTypingStartStub }); + + const keyboard = keyboardMock(this.$input); + + keyboard + .focus() + .type(value); + + this.clock.tick(TYPING_END_DELAY); + + keyboard.press('backspace'); + + assert.strictEqual(onTypingStartStub.callCount, 2); + }); + }); + + QUnit.test('should be triggered only once during continuous typing', function(assert) { + const onTypingStartStub = sinon.stub(); + + this.reinit({ onTypingStart: onTypingStartStub }); + + const keyboard = keyboardMock(this.$input); + + keyboard + .focus() + .type('n'); + + assert.strictEqual(onTypingStartStub.callCount, 1, 'called once'); + + keyboard.type('o'); + + assert.strictEqual(onTypingStartStub.callCount, 1, 'still called once'); + + this.clock.tick(TYPING_END_DELAY - 500); + + keyboard.type('t'); + + assert.strictEqual(onTypingStartStub.callCount, 1, 'still called once'); + + this.clock.tick(TYPING_END_DELAY + 500); + + keyboard.type('e'); + + assert.strictEqual(onTypingStartStub.callCount, 2, 'called again after typing end'); + }); + + QUnit.test('should be triggered with correct arguments', function(assert) { + assert.expect(4); + + this.reinit({ + onTypingStart: (e) => { + const { component, element, event } = e; + + assert.strictEqual(component, this.instance, 'component field is correct'); + assert.strictEqual(isRenderer(element), !!config().useJQuery, 'element is correct'); + assert.strictEqual($(element).is(this.$element), true, 'element field is correct'); + assert.strictEqual(event.type, 'input', 'e.event.type is correct'); + }, + }); + + keyboardMock(this.$input) + .focus() + .type('n'); + }); + }); + + QUnit.module('onTypingEnd event', { + beforeEach: function() { + this.clock = sinon.useFakeTimers(); + + moduleConfig.beforeEach.apply(this, arguments); + }, + afterEach: function() { + this.clock.restore(); + } + }, () => { + QUnit.test('should be triggered once if a character is entered in the input', function(assert) { + const onTypingEndStub = sinon.stub(); + + this.reinit({ onTypingEnd: onTypingEndStub }); + + const keyboard = keyboardMock(this.$input); + + keyboard + .focus() + .type('n'); + + assert.strictEqual(onTypingEndStub.callCount, 0, 'is not called immediately after entering'); + + this.clock.tick(TYPING_END_DELAY - 500); + + assert.strictEqual(onTypingEndStub.callCount, 0, 'is not called still'); + + this.clock.tick(500); + + assert.strictEqual(onTypingEndStub.callCount, 1, 'is called once after delay'); + }); + + QUnit.test('should be possible to update it at runtime', function(assert) { + const onTypingEndStub = sinon.stub(); + + this.instance.option('onTypingEnd', onTypingEndStub); + + keyboardMock(this.$input) + .focus() + .type('n'); + + this.clock.tick(TYPING_END_DELAY); + + assert.strictEqual(onTypingEndStub.callCount, 1); + }); + + QUnit.test('should not be called if the user continues to enter text during the delay', function(assert) { + const onTypingEndStub = sinon.stub(); + + this.reinit({ onTypingEnd: onTypingEndStub }); + + const keyboard = keyboardMock(this.$input); + + keyboard + .focus() + .type('n'); + + this.clock.tick(TYPING_END_DELAY - 10); + + keyboard.type('n'); + + this.clock.tick(20); + + assert.strictEqual(onTypingEndStub.callCount, 0, 'is not called'); + + this.clock.tick(TYPING_END_DELAY); + + assert.strictEqual(onTypingEndStub.callCount, 1, 'is called once after delay'); + }); + + [' ', '\n'].forEach(value => { + QUnit.test(`should be triggered if an empty character is entered in the input, value is '${value}'`, function(assert) { + const onTypingEndStub = sinon.stub(); + + this.reinit({ onTypingEnd: onTypingEndStub }); + + keyboardMock(this.$input) + .focus() + .type(value); + + this.clock.tick(TYPING_END_DELAY); + + assert.strictEqual(onTypingEndStub.callCount, 1); + }); + }); + + QUnit.test('should be triggered with correct arguments', function(assert) { + assert.expect(3); + + this.reinit({ + onTypingEnd: (e) => { + const { component, element } = e; + + assert.strictEqual(component, this.instance, 'component field is correct'); + assert.strictEqual(isRenderer(element), !!config().useJQuery, 'element is correct'); + assert.strictEqual($(element).is(this.$element), true, 'element field is correct'); + }, + }); + + keyboardMock(this.$input) + .focus() + .type('n'); + + this.clock.tick(TYPING_END_DELAY); + }); + + QUnit.test('should be called after sending a message', function(assert) { + const onTypingEndStub = sinon.stub(); + + this.reinit({ onTypingEnd: onTypingEndStub }); + + keyboardMock(this.$input) + .focus() + .type('new text message'); + + this.$sendButton.trigger('dxclick'); + + assert.strictEqual(onTypingEndStub.callCount, 1, 'called immediately'); + }); + }); + QUnit.module('Proxy state options', () => { [true, false].forEach(value => { QUnit.test('passed state options should be equal message box state options', function(assert) {