diff --git a/packages/devextreme-angular/src/ui/chat/index.ts b/packages/devextreme-angular/src/ui/chat/index.ts index bed128f62e73..5329c2bfcebf 100644 --- a/packages/devextreme-angular/src/ui/chat/index.ts +++ b/packages/devextreme-angular/src/ui/chat/index.ts @@ -139,6 +139,19 @@ export class DxChatComponent extends DxComponent implements OnDestroy, OnChanges } + /** + * [descr:dxChatOptions.title] + + */ + @Input() + get title(): string { + return this._getOption('title'); + } + set title(value: string) { + this._setOption('title', value); + } + + /** * [descr:WidgetOptions.visible] @@ -238,6 +251,13 @@ export class DxChatComponent extends DxComponent implements OnDestroy, OnChanges */ @Output() rtlEnabledChange: EventEmitter; + /** + + * This member supports the internal infrastructure and is not intended to be used directly from your code. + + */ + @Output() titleChange: EventEmitter; + /** * This member supports the internal infrastructure and is not intended to be used directly from your code. @@ -286,6 +306,7 @@ export class DxChatComponent extends DxComponent implements OnDestroy, OnChanges { emit: 'hoverStateEnabledChange' }, { emit: 'itemsChange' }, { emit: 'rtlEnabledChange' }, + { emit: 'titleChange' }, { emit: 'visibleChange' }, { emit: 'widthChange' } ]); diff --git a/packages/devextreme-scss/scss/widgets/base/chat/_index.scss b/packages/devextreme-scss/scss/widgets/base/chat/_index.scss new file mode 100644 index 000000000000..ad013d49ef64 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/base/chat/_index.scss @@ -0,0 +1,166 @@ +// adduse + +$chat-box-shadow: 0 0 2px 0 #0000001F, 0 4px 8px 0 #00000014; +$chat-width: 480px; +$chat-height: 660px; +$chat-padding: 20px; +$chat-border-radius: 24px; +$chat-information-font-size: 12px; +$chat-information-color: #707070; +$chat-avatar-size: 32px; +$chat-bubble-background-color-primary: #EBF3FC; +$chat-bubble-background-color-secondary: #F5F5F5; +$chat-bubble-border-radius: 12px; +$chat-text-area-height: 40px; + +.dx-chat { + display: flex; + flex-direction: column; + width: $chat-width; + height: $chat-height; + padding: $chat-padding; + border-radius: $chat-border-radius; + box-shadow: $chat-box-shadow; +} + +.dx-chat-header { + box-sizing: border-box; + display: flex; + align-items: center; + padding-bottom: 4px; +} + +.dx-chat-header-text { + margin: 0; +} + +.dx-chat-message-list { + box-sizing: border-box; + flex: 1; + overflow: auto; +} + +.dx-chat-message-list-content { + display: flex; + flex-direction: column; + padding: 0; + margin: 0; + overflow-y: auto; +} + +.dx-chat-message-group { + display: grid; + align-items: start; + row-gap: 4px; + margin: 24px 0; +} + +.dx-chat-message-group-alignment-start { + justify-items: start; + grid-template-columns: 44px 1fr; +} + +.dx-chat-message-group-alignment-end { + justify-items: end; +} + +.dx-chat-message-group-information { + display: flex; + grid-row: 1; +} + +.dx-chat-message-group-alignment-start .dx-chat-message-group-information { + grid-column: 2; +} + +.dx-chat-message-time, +.dx-chat-message-name { + font-size: $chat-information-font-size; + color: $chat-information-color; +} + +.dx-chat-message-name { + margin-right: 8px; +} + +.dx-chat-message-avatar { + grid-row: span 3; + display: flex; + align-items: center; + justify-content: center; + width: $chat-avatar-size; + height: $chat-avatar-size; + border-radius: 50%; + background-color: #878787; +} + +.dx-chat-message-bubble { + padding: 8px 12px; + max-width: 90%; + border-radius: $chat-bubble-border-radius; + background-color: $chat-bubble-background-color-secondary; +} + +.dx-chat-message-group-alignment-start .dx-chat-message-bubble.dx-chat-message-bubble-first { + border-bottom-left-radius: 0; +} + +.dx-chat-message-group-alignment-start .dx-chat-message-bubble.dx-chat-message-bubble-last { + border-top-left-radius: 0; +} + +.dx-chat-message-group-alignment-start .dx-chat-message-bubble-first.dx-chat-message-bubble-last { + border-bottom-left-radius: $chat-bubble-border-radius; + border-top-left-radius: $chat-bubble-border-radius; +} + +.dx-chat-message-group-alignment-start .dx-chat-message-bubble:not( + .dx-chat-message-bubble-first.dx-chat-message-bubble-last, + .dx-chat-message-bubble-first, + .dx-chat-message-bubble-last) { + border-bottom-left-radius: 0; + border-top-left-radius: 0; +} + +.dx-chat-message-group-alignment-end .dx-chat-message-bubble.dx-chat-message-bubble-first { + border-bottom-right-radius: 0; +} + +.dx-chat-message-group-alignment-end .dx-chat-message-bubble.dx-chat-message-bubble-last { + border-top-right-radius: 0; +} + +.dx-chat-message-group-alignment-end .dx-chat-message-bubble-first.dx-chat-message-bubble-last { + border-bottom-right-radius: $chat-bubble-border-radius; + border-top-right-radius: $chat-bubble-border-radius; +} + +.dx-chat-message-group-alignment-end .dx-chat-message-bubble:not( + .dx-chat-message-bubble-first.dx-chat-message-bubble-last, + .dx-chat-message-bubble-first, + .dx-chat-message-bubble-last) { + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} + +.dx-chat-message-group-alignment-start .dx-chat-message-bubble { + grid-column: 2; +} + +.dx-chat-message-group-alignment-end .dx-chat-message-bubble { + background-color: $chat-bubble-background-color-primary; +} + +.dx-chat-message-box { + display: flex; + align-items: center; +} + +.dx-chat .dx-chat-message-box-text-area { + flex-grow: 1; + height: $chat-text-area-height; +} + +.dx-chat-message-box-button { + margin-left: 8px; +} diff --git a/packages/devextreme-scss/scss/widgets/fluent/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/_index.scss index 28988cce559a..3cc0783fd49a 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/_index.scss @@ -1,5 +1,6 @@ // public widgets @use "./box"; +@use "./chat"; @use "./responsiveBox"; @use "./button"; @use "./buttonGroup"; diff --git a/packages/devextreme-scss/scss/widgets/fluent/chat/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/chat/_index.scss new file mode 100644 index 000000000000..adbf8a3cf420 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/fluent/chat/_index.scss @@ -0,0 +1,3 @@ +@use "../../base/chat"; + +// adduse diff --git a/packages/devextreme-scss/scss/widgets/generic/_index.scss b/packages/devextreme-scss/scss/widgets/generic/_index.scss index 1513c7848b93..66d0cf00b185 100644 --- a/packages/devextreme-scss/scss/widgets/generic/_index.scss +++ b/packages/devextreme-scss/scss/widgets/generic/_index.scss @@ -1,5 +1,6 @@ // public widgets @use "./box"; +@use "./chat"; @use "./responsiveBox"; @use "./button"; @use "./buttonGroup"; diff --git a/packages/devextreme-scss/scss/widgets/generic/chat/_index.scss b/packages/devextreme-scss/scss/widgets/generic/chat/_index.scss new file mode 100644 index 000000000000..adbf8a3cf420 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/generic/chat/_index.scss @@ -0,0 +1,3 @@ +@use "../../base/chat"; + +// adduse diff --git a/packages/devextreme-scss/scss/widgets/material/_index.scss b/packages/devextreme-scss/scss/widgets/material/_index.scss index 28988cce559a..3cc0783fd49a 100644 --- a/packages/devextreme-scss/scss/widgets/material/_index.scss +++ b/packages/devextreme-scss/scss/widgets/material/_index.scss @@ -1,5 +1,6 @@ // public widgets @use "./box"; +@use "./chat"; @use "./responsiveBox"; @use "./button"; @use "./buttonGroup"; diff --git a/packages/devextreme-scss/scss/widgets/material/chat/_index.scss b/packages/devextreme-scss/scss/widgets/material/chat/_index.scss new file mode 100644 index 000000000000..adbf8a3cf420 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/material/chat/_index.scss @@ -0,0 +1,3 @@ +@use "../../base/chat"; + +// adduse diff --git a/packages/devextreme-themebuilder/tests/data/dependencies.ts b/packages/devextreme-themebuilder/tests/data/dependencies.ts index 5ffc1c3061a7..f99b1965535f 100644 --- a/packages/devextreme-themebuilder/tests/data/dependencies.ts +++ b/packages/devextreme-themebuilder/tests/data/dependencies.ts @@ -16,6 +16,7 @@ export const dependencies: FlatStylesDependencies = { buttongroup: ['validation', 'button'], dropdownbutton: ['validation', 'button', 'buttongroup', 'popup', 'loadindicator', 'loadpanel', 'scrollview', 'list'], calendar: ['validation', 'button'], + chat: ['button', 'loadindicator', 'textbox', 'validation'], checkbox: ['validation'], numberbox: ['validation', 'button', 'loadindicator'], colorbox: ['validation', 'button', 'loadindicator', 'numberbox', 'textbox', 'popup'], diff --git a/packages/devextreme-vue/src/chat.ts b/packages/devextreme-vue/src/chat.ts index 659c1c3be3fa..3c49d970deab 100644 --- a/packages/devextreme-vue/src/chat.ts +++ b/packages/devextreme-vue/src/chat.ts @@ -13,6 +13,7 @@ type AccessibleOptions = Pick; @@ -32,6 +33,7 @@ const DxChat = createComponent({ onMessageSend: Function, onOptionChanged: Function, rtlEnabled: Boolean, + title: String, visible: Boolean, width: [Function, Number, String] }, @@ -48,6 +50,7 @@ const DxChat = createComponent({ "update:onMessageSend": null, "update:onOptionChanged": null, "update:rtlEnabled": null, + "update:title": null, "update:visible": null, "update:width": null, }, diff --git a/packages/devextreme/js/__internal/ui/chat/chat.ts b/packages/devextreme/js/__internal/ui/chat/chat.ts index 1b011481fedf..0eeff3a722cd 100644 --- a/packages/devextreme/js/__internal/ui/chat/chat.ts +++ b/packages/devextreme/js/__internal/ui/chat/chat.ts @@ -3,17 +3,27 @@ import $ from '@js/core/renderer'; import type { Properties } from '@js/ui/chat'; import Widget from '../widget'; +import ChatHeader from './chat_header'; +import MessageBox from './chat_message_box'; +import MessageList from './chat_message_list'; const CHAT_CLASS = 'dx-chat'; +const MOCK_CURRENT_USER_ID = 'CURRENT_USER_ID'; + class Chat extends Widget { + _chatHeader?: ChatHeader; + + _messageBox?: MessageBox; + + _messageList?: MessageList; + _getDefaultOptions(): Properties { return { ...super._getDefaultOptions(), - ...{ - items: [], - onMessageSend: undefined, - }, + title: '', + items: [], + onMessageSend: undefined, }; } @@ -21,13 +31,48 @@ class Chat extends Widget { $(this.element()).addClass(CHAT_CLASS); super._initMarkup(); + + this._renderHeader(); + this._renderMessageList(); + this._renderMessageBox(); + } + + _renderHeader(): void { + const { title } = this.option(); + + const $header = $('
').appendTo(this.element()); + + // @ts-expect-error + this._chatHeader = this._createComponent($header, ChatHeader, { title }); + } + + _renderMessageList(): void { + const { items } = this.option(); + + const $messageList = $('
').appendTo(this.element()); + + this._messageList = this._createComponent($messageList, MessageList, { + items, + currentUserId: MOCK_CURRENT_USER_ID, + }); + } + + _renderMessageBox(): void { + const $messageBox = $('
').appendTo(this.element()); + + this._messageBox = this._createComponent($messageBox, MessageBox, {}); } _optionChanged(args: Record): void { - const { name } = args; + const { name, value } = args; switch (name) { + case 'title': + // @ts-expect-error + this._chatHeader?.option(name, value); + break; case 'items': + this._invalidate(); break; case 'onMessageSend': break; diff --git a/packages/devextreme/js/__internal/ui/chat/chat_avatar.ts b/packages/devextreme/js/__internal/ui/chat/chat_avatar.ts new file mode 100644 index 000000000000..67c335901e47 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/chat/chat_avatar.ts @@ -0,0 +1,57 @@ +import $ from '@js/core/renderer'; +import type { WidgetOptions } from '@js/ui/widget/ui.widget'; + +import Widget from '../widget'; + +const CHAT_MESSAGE_AVATAR_CLASS = 'dx-chat-message-avatar'; +const CHAT_MESSAGE_AVATAR_INITIALS_CLASS = 'dx-chat-message-avatar-initials'; + +export interface AvatarOptions extends WidgetOptions { + name?: string; +} + +class Avatar extends Widget { + _getDefaultOptions(): AvatarOptions { + return { + ...super._getDefaultOptions(), + name: '', + }; + } + + _getAvatarInitials(name: string): string { + const initials = name.charAt(0).toUpperCase(); + + return initials; + } + + _initMarkup(): void { + $(this.element()).addClass(CHAT_MESSAGE_AVATAR_CLASS); + + super._initMarkup(); + + const $initials = $('
').addClass(CHAT_MESSAGE_AVATAR_INITIALS_CLASS); + + const { name } = this.option(); + + if (name) { + const text = this._getAvatarInitials(name); + + $initials.text(text); + } + + $initials.appendTo(this.element()); + } + + _optionChanged(args: Record): void { + const { name } = args; + + switch (name) { + case 'name': + break; + default: + super._optionChanged(args); + } + } +} + +export default Avatar; diff --git a/packages/devextreme/js/__internal/ui/chat/chat_header.ts b/packages/devextreme/js/__internal/ui/chat/chat_header.ts new file mode 100644 index 000000000000..427565f55d99 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/chat/chat_header.ts @@ -0,0 +1,62 @@ +import type { Properties } from '@js/core/dom_component'; +import DOMComponent from '@js/core/dom_component'; +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; + +const CHAT_HEADER_CLASS = 'dx-chat-header'; +const CHAT_HEADER_TEXT_CLASS = 'dx-chat-header-text'; + +export interface ChatHeaderProperties extends Properties { + title: string; +} + +class ChatHeader extends DOMComponent { + private _$text?: dxElementWrapper; + + _getDefaultOptions(): ChatHeaderProperties { + return { + // @ts-expect-error + ...super._getDefaultOptions(), + title: '', + } as ChatHeaderProperties; + } + + _init(): void { + // @ts-expect-error + super._init(); + + $(this.element()).addClass(CHAT_HEADER_CLASS); + } + + _initMarkup(): void { + // @ts-expect-error + super._initMarkup(); + + this._renderText(); + } + + _renderText(): void { + const { title } = this.option(); + + this._$text = $('
') + .addClass(CHAT_HEADER_TEXT_CLASS) + .text(title) + .appendTo(this.element()); + } + + _optionChanged(args: Record): void { + const { name, value } = args; + + switch (name) { + case 'title': + // @ts-expect-error + this._$text?.text(value); + break; + default: + // @ts-expect-error + super._optionChanged(args); + } + } +} + +export default ChatHeader; diff --git a/packages/devextreme/js/__internal/ui/chat/chat_message_box.ts b/packages/devextreme/js/__internal/ui/chat/chat_message_box.ts new file mode 100644 index 000000000000..1eaa7863aff4 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/chat/chat_message_box.ts @@ -0,0 +1,47 @@ +import $ from '@js/core/renderer'; +import Button from '@js/ui/button'; + +import type dxTextArea from '../../../ui/text_area'; +import TextArea from '../m_text_area'; +import Widget from '../widget'; + +const CHAT_MESSAGE_BOX_CLASS = 'dx-chat-message-box'; +const CHAT_MESSAGE_BOX_TEXTAREA_CLASS = 'dx-chat-message-box-text-area'; +const CHAT_MESSAGE_BOX_BUTTON_CLASS = 'dx-chat-message-box-button'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +class MessageBox extends Widget { + _textArea?: dxTextArea; + + _button?: Button; + + _initMarkup(): void { + $(this.element()).addClass(CHAT_MESSAGE_BOX_CLASS); + + super._initMarkup(); + + this._renderTextArea(); + this._renderButton(); + } + + _renderTextArea(): void { + const $textArea = $('
') + .addClass(CHAT_MESSAGE_BOX_TEXTAREA_CLASS) + .appendTo(this.element()); + + this._textArea = this._createComponent($textArea, TextArea, {}); + } + + _renderButton(): void { + const $button = $('
') + .addClass(CHAT_MESSAGE_BOX_BUTTON_CLASS) + .appendTo(this.element()); + + this._button = this._createComponent($button, Button, { + icon: 'send', + stylingMode: 'text', + }); + } +} + +export default MessageBox; diff --git a/packages/devextreme/js/__internal/ui/chat/chat_message_bubble.ts b/packages/devextreme/js/__internal/ui/chat/chat_message_bubble.ts new file mode 100644 index 000000000000..8ddbc18384c3 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/chat/chat_message_bubble.ts @@ -0,0 +1,44 @@ +import $ from '@js/core/renderer'; +import type { WidgetOptions } from '@js/ui/widget/ui.widget'; + +import Widget from '../widget'; + +const CHAT_MESSAGE_BUBBLE_CLASS = 'dx-chat-message-bubble'; + +export interface MessageBubbleOptions extends WidgetOptions { + text?: string; +} + +class MessageBubble extends Widget { + _getDefaultOptions(): MessageBubbleOptions { + return { + ...super._getDefaultOptions(), + text: '', + }; + } + + _initMarkup(): void { + const $bubble = $(this.element()).addClass(CHAT_MESSAGE_BUBBLE_CLASS); + + const { text } = this.option(); + + if (text) { + $bubble.text(text); + } + + super._initMarkup(); + } + + _optionChanged(args: Record): void { + const { name } = args; + + switch (name) { + case 'text': + break; + default: + super._optionChanged(args); + } + } +} + +export default MessageBubble; diff --git a/packages/devextreme/js/__internal/ui/chat/chat_message_group.ts b/packages/devextreme/js/__internal/ui/chat/chat_message_group.ts new file mode 100644 index 000000000000..06796509a2d1 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/chat/chat_message_group.ts @@ -0,0 +1,135 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import $ from '@js/core/renderer'; +import type { Message } from '@js/ui/chat'; +import type { WidgetOptions } from '@js/ui/widget/ui.widget'; + +import Widget from '../widget'; +import Avatar from './chat_avatar'; +import MessageBubble from './chat_message_bubble'; + +const CHAT_MESSAGE_GROUP_CLASS = 'dx-chat-message-group'; +const CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS = 'dx-chat-message-group-alignment-start'; +const CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS = 'dx-chat-message-group-alignment-end'; +const CHAT_MESSAGE_GROUP_INFORMATION_CLASS = 'dx-chat-message-group-information'; +const CHAT_MESSAGE_TIME_CLASS = 'dx-chat-message-time'; +const CHAT_MESSAGE_NAME_CLASS = 'dx-chat-message-name'; +const CHAT_MESSAGE_BUBBLE_FIRST_CLASS = 'dx-chat-message-bubble-first'; +const CHAT_MESSAGE_BUBBLE_LAST_CLASS = 'dx-chat-message-bubble-last'; + +export interface MessageGroupOptions extends WidgetOptions { + messages: Message[]; + alignment: 'start' | 'end'; +} + +class MessageGroup extends Widget { + _avatar?: Avatar; + + _getDefaultOptions(): MessageGroupOptions { + return { + ...super._getDefaultOptions(), + messages: [], + alignment: 'start', + }; + } + + _getAlignmentClass(): string { + const { alignment } = this.option(); + + const alignmentClass = alignment === 'start' + ? CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS + : CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS; + + return alignmentClass; + } + + _initMarkup(): void { + const { alignment, messages } = this.option(); + + const alignmentClass = this._getAlignmentClass(); + + $(this.element()) + .addClass(CHAT_MESSAGE_GROUP_CLASS) + .addClass(alignmentClass); + + super._initMarkup(); + + if (alignment === 'start') { + const authorName = messages[0].author?.name; + + const $avatar = $('
').appendTo(this.element()); + + this._avatar = this._createComponent($avatar, Avatar, { + name: authorName, + }); + } + + this._renderMessageGroupInformation(messages?.[0]); + this._renderMessageBubbles(messages); + } + + _renderMessageBubbles(messages): void { + messages.forEach((message, index) => { + const $bubble = $('
'); + + const isFirst = index === 0; + const isLast = index === messages.length - 1; + + if (isFirst) { + $bubble.addClass(CHAT_MESSAGE_BUBBLE_FIRST_CLASS); + } + + if (isLast) { + $bubble.addClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS); + } + + $bubble.appendTo(this.element()); + + this._createComponent($bubble, MessageBubble, { + text: message.text, + }); + }); + } + + _renderName(name, $element): void { + $('
') + .addClass(CHAT_MESSAGE_NAME_CLASS) + .text(name) + .appendTo($element); + } + + _renderTime(timestamp, $element): void { + const options: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', hour12: false }; + const dateTime = new Date(Number(timestamp)); + const dateTimeString = dateTime.toLocaleTimeString(undefined, options); + + $('
') + .addClass(CHAT_MESSAGE_TIME_CLASS) + .text(dateTimeString) + .appendTo($element); + } + + _renderMessageGroupInformation(message): void { + const { timestamp, author } = message; + const $messageGroupInformation = $('
').addClass(CHAT_MESSAGE_GROUP_INFORMATION_CLASS); + + this._renderName(author.name, $messageGroupInformation); + this._renderTime(timestamp, $messageGroupInformation); + + $messageGroupInformation.appendTo(this.element()); + } + + _optionChanged(args: Record): void { + const { name } = args; + + switch (name) { + case 'messages': + case 'alignment': + this._invalidate(); + break; + default: + super._optionChanged(args); + } + } +} + +export default MessageGroup; diff --git a/packages/devextreme/js/__internal/ui/chat/chat_message_list.ts b/packages/devextreme/js/__internal/ui/chat/chat_message_list.ts new file mode 100644 index 000000000000..c3d7716ee33a --- /dev/null +++ b/packages/devextreme/js/__internal/ui/chat/chat_message_list.ts @@ -0,0 +1,99 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import $ from '@js/core/renderer'; +import type { Message } from '@js/ui/chat'; +import type { WidgetOptions } from '@js/ui/widget/ui.widget'; + +import Widget from '../widget'; +import MessageGroup from './chat_message_group'; + +const CHAT_MESSAGE_LIST_CLASS = 'dx-chat-message-list'; +const CHAT_MESSAGE_LIST_CONTENT_CLASS = 'dx-chat-message-list-content'; + +export interface MessageListOptions extends WidgetOptions { + items?: Message[]; + currentUserId?: string; +} + +class MessageList extends Widget { + _getDefaultOptions(): MessageListOptions { + return { + ...super._getDefaultOptions(), + items: [], + currentUserId: '', + }; + } + + _initMarkup(): void { + $(this.element()).addClass(CHAT_MESSAGE_LIST_CLASS); + + super._initMarkup(); + + this._renderMessageListContent(); + } + + _isCurrentUser(id): boolean { + const { currentUserId } = this.option(); + + return currentUserId === id; + } + + _messageGroupAlignment(id): 'start' | 'end' { + return this._isCurrentUser(id) ? 'end' : 'start'; + } + + _createMessageGroupComponent(items, userId): void { + const $messageGroup = $('
').appendTo(this.element()); + + this._createComponent($messageGroup, MessageGroup, { + messages: items, + alignment: this._messageGroupAlignment(userId), + }); + } + + _renderMessageListContent(): void { + const { items } = this.option(); + + if (!items?.length) { + return; + } + + const $content = $('
').addClass(CHAT_MESSAGE_LIST_CONTENT_CLASS); + + let currentMessageGroupUserId = items[0]?.author?.id; + let currentMessageGroupItems: Message[] = []; + + items.forEach((item, index) => { + const id = item?.author?.id; + + if (id === currentMessageGroupUserId) { + currentMessageGroupItems.push(item); + } else { + this._createMessageGroupComponent(currentMessageGroupItems, currentMessageGroupUserId); + + currentMessageGroupUserId = id; + currentMessageGroupItems = []; + currentMessageGroupItems.push(item); + } + + if (items.length - 1 === index) { + this._createMessageGroupComponent(currentMessageGroupItems, currentMessageGroupUserId); + } + }); + + $content.appendTo(this.element()); + } + + _optionChanged(args: Record): void { + const { name } = args; + + switch (name) { + case 'items': + case 'currentUserId': + break; + default: + super._optionChanged(args); + } + } +} + +export default MessageList; diff --git a/packages/devextreme/js/ui/chat.d.ts b/packages/devextreme/js/ui/chat.d.ts index 19dc6d7143f6..e89cde668989 100644 --- a/packages/devextreme/js/ui/chat.d.ts +++ b/packages/devextreme/js/ui/chat.d.ts @@ -104,6 +104,12 @@ export type Message = { * @docid */ export interface dxChatOptions extends WidgetOptions { + /** + * @docid + * @default '' + * @public + */ + title?: string; /** * @docid * @fires dxChatOptions.onOptionChanged diff --git a/packages/devextreme/js/ui/chat.js b/packages/devextreme/js/ui/chat.js index 68a5e5c6a414..d1e07289a2e1 100644 --- a/packages/devextreme/js/ui/chat.js +++ b/packages/devextreme/js/ui/chat.js @@ -2,6 +2,8 @@ import Chat from '../__internal/ui/chat/chat'; export default Chat; +// STYLE chat + /** * @name dxChatOptions.accessKey * @hidden diff --git a/packages/devextreme/testing/tests/DevExpress.serverSide/chat.js b/packages/devextreme/testing/tests/DevExpress.serverSide/chat.js new file mode 100644 index 000000000000..a749d98b12d7 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.serverSide/chat.js @@ -0,0 +1 @@ +import '../DevExpress.ui.widgets/chat.markup.tests.js'; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.markup.tests.js index 34d6a5baab63..926119642c44 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.markup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.markup.tests.js @@ -9,6 +9,30 @@ QUnit.testStart(function() { }); const CHAT_CLASS = 'dx-chat'; +const CHAT_HEADER_CLASS = 'dx-chat-header'; +const CHAT_HEADER_TEXT_CLASS = 'dx-chat-header-text'; +const CHAT_MESSAGE_BOX_CLASS = 'dx-chat-message-box'; +const CHAT_MESSAGE_BOX_TEXTAREA_CLASS = 'dx-chat-message-box-text-area'; +const CHAT_MESSAGE_BOX_BUTTON_CLASS = 'dx-chat-message-box-button'; +const CHAT_MESSAGE_LIST_CLASS = 'dx-chat-message-list'; +const CHAT_MESSAGE_LIST_CONTENT_CLASS = 'dx-chat-message-list-content'; +const CHAT_MESSAGE_GROUP_CLASS = 'dx-chat-message-group'; +const CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS = 'dx-chat-message-group-alignment-start'; +const CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS = 'dx-chat-message-group-alignment-end'; +const CHAT_MESSAGE_GROUP_INFORMATION_CLASS = 'dx-chat-message-group-information'; +const CHAT_MESSAGE_TIME_CLASS = 'dx-chat-message-time'; +const CHAT_MESSAGE_NAME_CLASS = 'dx-chat-message-name'; +const CHAT_MESSAGE_BUBBLE_CLASS = 'dx-chat-message-bubble'; +const CHAT_MESSAGE_BUBBLE_FIRST_CLASS = 'dx-chat-message-bubble-first'; +const CHAT_MESSAGE_BUBBLE_LAST_CLASS = 'dx-chat-message-bubble-last'; +const CHAT_MESSAGE_AVATAR_CLASS = 'dx-chat-message-avatar'; +const CHAT_MESSAGE_AVATAR_INITIALS_CLASS = 'dx-chat-message-avatar-initials'; + +const TEXTAREA_CLASS = 'dx-textarea'; +const BUTTON_CLASS = 'dx-button'; + +const MOCK_COMPANION_USER_ID = 'COMPANION_USER_ID'; +const MOCK_CURRENT_USER_ID = 'CURRENT_USER_ID'; const moduleConfig = { beforeEach: function() { @@ -17,12 +41,212 @@ const moduleConfig = { this.instance = this.$element.dxChat('instance'); }; - init(); + const userFirst = { + id: MOCK_COMPANION_USER_ID, + name: 'First', + }; + + const userSecond = { + id: MOCK_CURRENT_USER_ID, + name: 'Second', + }; + + const now = Date.now(); + + const messages = [ + { + timestamp: String(now), + author: userFirst, + text: 'userFirst', + }, + { + timestamp: String(now), + author: userFirst, + text: 'userFirst', + }, + { + timestamp: String(now), + author: userFirst, + text: 'userFirst', + }, + { + timestamp: String(now), + author: userSecond, + text: 'userSecond', + }, + { + timestamp: String(now), + author: userSecond, + text: 'userSecond', + }, + { + timestamp: String(now), + author: userSecond, + text: 'userSecond', + }, + { + timestamp: String(now), + author: userFirst, + text: 'userFirst', + }, + ]; + + const options = { + items: messages, + }; + + init(options); } }; -QUnit.module('Chat markup', moduleConfig, () => { +QUnit.module('Render', moduleConfig, () => { + QUnit.test('Header should be rendered', function(assert) { + const $header = this.$element.find(`.${CHAT_HEADER_CLASS}`); + + assert.strictEqual($header.length, 1); + }); + + QUnit.test('Header text element should be rendered', function(assert) { + const $headerText = this.$element.find(`.${CHAT_HEADER_TEXT_CLASS}`); + + assert.strictEqual($headerText.length, 1); + }); + + QUnit.test('Message box should be rendered', function(assert) { + const $messageBox = this.$element.find(`.${CHAT_MESSAGE_BOX_CLASS}`); + + assert.strictEqual($messageBox.length, 1); + }); + + QUnit.test('Message box textarea should be rendered', function(assert) { + const $textArea = this.$element.find(`.${TEXTAREA_CLASS}`); + + assert.strictEqual($textArea.length, 1); + }); + + QUnit.test('Message box button should be rendered', function(assert) { + const $button = this.$element.find(`.${BUTTON_CLASS}`); + + assert.strictEqual($button.length, 1); + }); + + QUnit.test('Message list should be rendered', function(assert) { + const $messageList = this.$element.find(`.${CHAT_MESSAGE_LIST_CLASS}`); + + assert.strictEqual($messageList.length, 1); + }); + + QUnit.test('Message list content should be rendered', function(assert) { + const $messageListContent = this.$element.find(`.${CHAT_MESSAGE_LIST_CONTENT_CLASS}`); + + assert.strictEqual($messageListContent.length, 1); + }); + + QUnit.test('Message list content should be rendered if items is empty', function(assert) { + this.instance.option({ items: [] }); + const $messageListContent = this.$element.find(`.${CHAT_MESSAGE_LIST_CONTENT_CLASS}`); + + assert.strictEqual($messageListContent.length, 0); + }); + + QUnit.test('Message groups should be rendered', function(assert) { + const $messageGroups = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`); + + assert.strictEqual($messageGroups.length, 3); + }); + + QUnit.test('Avatar should be rendered in first message group', function(assert) { + const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); + const $avatar = $messageGroup.find(`.${CHAT_MESSAGE_AVATAR_CLASS}`); + + assert.strictEqual($avatar.length, 1); + }); + + QUnit.test('Avatar initials element should be rendered in avatar', function(assert) { + const $avatar = this.$element.find(`.${CHAT_MESSAGE_AVATAR_CLASS}`).eq(0); + const $initials = $avatar.find(`.${CHAT_MESSAGE_AVATAR_INITIALS_CLASS}`); + + assert.strictEqual($initials.length, 1); + }); + + QUnit.test('Avatar should not be rendered in second message group', function(assert) { + const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(1); + const $avatar = $messageGroup.find(`.${CHAT_MESSAGE_AVATAR_CLASS}`); + + assert.strictEqual($avatar.length, 0); + }); + + QUnit.test('Message group information should be rendered', function(assert) { + const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); + const $information = $messageGroup.find(`.${CHAT_MESSAGE_GROUP_INFORMATION_CLASS}`); + + assert.strictEqual($information.length, 1); + }); + + QUnit.test('Message group time should be rendered', function(assert) { + const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); + const $time = $messageGroup.find(`.${CHAT_MESSAGE_TIME_CLASS}`); + + assert.strictEqual($time.length, 1); + }); + + QUnit.test('Message group user name should be rendered', function(assert) { + const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); + const $name = $messageGroup.find(`.${CHAT_MESSAGE_NAME_CLASS}`); + + assert.strictEqual($name.length, 1); + }); + + QUnit.test('Message bubble should be rendered in message group', function(assert) { + const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); + const $bubbles = $messageGroup.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($bubbles.length, 3); + }); +}); + +QUnit.module('Classes', moduleConfig, () => { QUnit.test(`Chat should have ${CHAT_CLASS} class`, function(assert) { assert.strictEqual(this.$element.hasClass(CHAT_CLASS), true); }); + + QUnit.test(`Message box textarea should have ${CHAT_MESSAGE_BOX_TEXTAREA_CLASS} class`, function(assert) { + const $textArea = this.$element.find(`.${TEXTAREA_CLASS}`); + + assert.strictEqual($textArea.hasClass(CHAT_MESSAGE_BOX_TEXTAREA_CLASS), true); + }); + + QUnit.test(`Message box button should have ${CHAT_MESSAGE_BOX_BUTTON_CLASS} class`, function(assert) { + const $button = this.$element.find(`.${BUTTON_CLASS}`); + + assert.strictEqual($button.hasClass(CHAT_MESSAGE_BOX_BUTTON_CLASS), true); + }); + + QUnit.test(`First message group should have ${CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS} class`, function(assert) { + const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); + + assert.strictEqual($messageGroup.hasClass(CHAT_MESSAGE_GROUP_ALIGNMENT_START_CLASS), true); + }); + + QUnit.test(`Second message group should have ${CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS} class`, function(assert) { + const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(1); + + assert.strictEqual($messageGroup.hasClass(CHAT_MESSAGE_GROUP_ALIGNMENT_END_CLASS), true); + }); + + QUnit.test('Message bubble should have correct classes', function(assert) { + const $firstMessageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); + const $bubbles = $firstMessageGroup.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($bubbles.eq(0).hasClass(CHAT_MESSAGE_BUBBLE_FIRST_CLASS), true); + assert.strictEqual($bubbles.eq(1).hasClass(CHAT_MESSAGE_BUBBLE_FIRST_CLASS), false); + assert.strictEqual($bubbles.eq(1).hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), false); + assert.strictEqual($bubbles.eq(2).hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), true); + + const $lastMessageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(2); + const $bubble = $lastMessageGroup.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($bubble.hasClass(CHAT_MESSAGE_BUBBLE_FIRST_CLASS), true); + assert.strictEqual($bubble.hasClass(CHAT_MESSAGE_BUBBLE_LAST_CLASS), true); + }); }); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.tests.js index 2dea1f7ec027..ae73be3fb6e5 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chat.tests.js @@ -4,8 +4,25 @@ import fx from 'animation/fx'; import 'generic_light.css!'; -// eslint-disable-next-line no-unused-vars -const CHAT_CLASS = 'dx-chat'; +const CHAT_HEADER_TEXT_CLASS = 'dx-chat-header-text'; +const CHAT_MESSAGE_GROUP_CLASS = 'dx-chat-message-group'; +const CHAT_MESSAGE_TIME_CLASS = 'dx-chat-message-time'; +const CHAT_MESSAGE_NAME_CLASS = 'dx-chat-message-name'; +const CHAT_MESSAGE_BUBBLE_CLASS = 'dx-chat-message-bubble'; +const CHAT_MESSAGE_AVATAR_INITIALS_CLASS = 'dx-chat-message-avatar-initials'; + +const MOCK_CHAT_HEADER_TEXT = 'Chat title'; +const MOCK_COMPANION_USER_ID = 'COMPANION_USER_ID'; +const MOCK_CURRENT_USER_ID = 'CURRENT_USER_ID'; +const NOW = '1721747399083'; + +const getDateTimeString = (timestamp) => { + const options = { hour: '2-digit', minute: '2-digit', hour12: false }; + const dateTime = new Date(Number(timestamp)); + const dateTimeString = dateTime.toLocaleTimeString(undefined, options); + + return dateTimeString; +}; QUnit.testStart(() => { const markup = '
'; @@ -22,7 +39,40 @@ const moduleConfig = { this.instance = this.$element.dxChat('instance'); }; - init(); + const userFirst = { + id: MOCK_COMPANION_USER_ID, + name: 'First', + }; + + const userSecond = { + id: MOCK_CURRENT_USER_ID, + name: 'Second', + }; + + const messages = [ + { + timestamp: NOW, + author: userFirst, + text: 'userFirst', + }, + { + timestamp: NOW, + author: userFirst, + text: 'userFirst', + }, + { + timestamp: NOW, + author: userSecond, + text: 'userSecond', + }, + ]; + + const options = { + title: MOCK_CHAT_HEADER_TEXT, + items: messages, + }; + + init(options); }, afterEach: function() { fx.off = false; @@ -34,3 +84,60 @@ QUnit.module('Chat initialization', moduleConfig, () => { assert.ok(this.instance instanceof Chat); }); }); + +QUnit.module('Header', moduleConfig, () => { + QUnit.test('Header text element should have correct text', function(assert) { + const $header = this.$element.find(`.${CHAT_HEADER_TEXT_CLASS}`); + + assert.strictEqual($header.text(), MOCK_CHAT_HEADER_TEXT); + }); + + QUnit.test('Header text element should have correct text after runtime change', function(assert) { + this.instance.option({ title: 'new title' }); + + const $header = this.$element.find(`.${CHAT_HEADER_TEXT_CLASS}`); + + assert.strictEqual($header.text(), 'new title'); + }); +}); + +QUnit.module('Message group', moduleConfig, () => { + QUnit.test('Message groups should has correct bubble elements count', function(assert) { + const $firstMessageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); + const $secondMessageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(1); + + const $firstMessageGroupBubbles = $firstMessageGroup.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + const $secondMessageGroupBubbles = $secondMessageGroup.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`); + + assert.strictEqual($firstMessageGroupBubbles.length, 2); + assert.strictEqual($secondMessageGroupBubbles.length, 1); + }); + + QUnit.test('Avatar should have correct text', function(assert) { + const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); + const $avatarText = $messageGroup.find(`.${CHAT_MESSAGE_AVATAR_INITIALS_CLASS}`); + + assert.strictEqual($avatarText.text(), 'F'); + }); + + QUnit.test('Message group time should be correct', function(assert) { + const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); + const $time = $messageGroup.find(`.${CHAT_MESSAGE_TIME_CLASS}`); + + assert.strictEqual($time.text(), getDateTimeString(NOW)); + }); + + QUnit.test('Message group user name should be correct', function(assert) { + const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); + const $name = $messageGroup.find(`.${CHAT_MESSAGE_NAME_CLASS}`); + + assert.strictEqual($name.text(), 'First'); + }); + + QUnit.test('Message bubble should have correct text', function(assert) { + const $messageGroup = this.$element.find(`.${CHAT_MESSAGE_GROUP_CLASS}`).eq(0); + const $bubble = $messageGroup.find(`.${CHAT_MESSAGE_BUBBLE_CLASS}`).eq(0); + + assert.strictEqual($bubble.text(), 'userFirst'); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui/defaultOptions.tests.js b/packages/devextreme/testing/tests/DevExpress.ui/defaultOptions.tests.js index 586ec6517980..b50e22d27bbd 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui/defaultOptions.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui/defaultOptions.tests.js @@ -1336,6 +1336,7 @@ testComponentDefaults(ColorBox, testComponentDefaults(Chat, {}, { + title: '', onMessageSend: null, } ); diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index 3de288b345e8..197f0074d95c 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -9453,6 +9453,10 @@ declare module DevExpress.ui { * @deprecated [depNote:dxChatOptions] */ export interface dxChatOptions extends WidgetOptions { + /** + * [descr:dxChatOptions.title] + */ + title?: string; /** * [descr:dxChatOptions.items] */