diff --git a/e2e/testcafe-devextreme/tests/chat/etalons/Messagelist with editing context menu (fluent-blue-light).png b/e2e/testcafe-devextreme/tests/chat/etalons/Messagelist with editing context menu (fluent-blue-light).png new file mode 100644 index 000000000000..efb3555ee9ed Binary files /dev/null and b/e2e/testcafe-devextreme/tests/chat/etalons/Messagelist with editing context menu (fluent-blue-light).png differ diff --git a/e2e/testcafe-devextreme/tests/chat/etalons/Messagelist with editing context menu (generic-light).png b/e2e/testcafe-devextreme/tests/chat/etalons/Messagelist with editing context menu (generic-light).png new file mode 100644 index 000000000000..271e95919ff3 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/chat/etalons/Messagelist with editing context menu (generic-light).png differ diff --git a/e2e/testcafe-devextreme/tests/chat/etalons/Messagelist with editing context menu (material-blue-light).png b/e2e/testcafe-devextreme/tests/chat/etalons/Messagelist with editing context menu (material-blue-light).png new file mode 100644 index 000000000000..d0270cffebbb Binary files /dev/null and b/e2e/testcafe-devextreme/tests/chat/etalons/Messagelist with editing context menu (material-blue-light).png differ diff --git a/e2e/testcafe-devextreme/tests/chat/messageList.ts b/e2e/testcafe-devextreme/tests/chat/messageList.ts index d1540e450f0c..24e47f268b2f 100644 --- a/e2e/testcafe-devextreme/tests/chat/messageList.ts +++ b/e2e/testcafe-devextreme/tests/chat/messageList.ts @@ -268,6 +268,43 @@ test('Messagelist options showDayHeaders, showUserName and showMessageTimestamp }); }); +test('Message list with editing context menu', async (t) => { + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); + const chat = new Chat('#container'); + + await t + .rightClick(chat.getMessage(2)) + .pressKey('down') + .pressKey('down'); + + await testScreenshot(t, takeScreenshot, 'Messagelist with editing context menu.png', { element: '#container' }); + + await t + .expect(compareResults.isValid()) + .ok(compareResults.errorMessages()); +}).before(async () => { + const userFirst = createUser(1, 'First'); + const userSecond = createUser(2, 'Second'); + + const items = [ + { author: userFirst, text: 'AAA' }, + { author: userFirst, text: 'BBB' }, + { author: userSecond, text: 'CCC' }, + ]; + + return createWidget('dxChat', { + items, + editing: { + allowUpdating: true, + allowDeleting: true, + }, + user: userSecond, + width: 400, + height: 600, + showDayHeaders: false, + }); +}); + fixture`ChatMessageList: dayHeaders` .page(url(__dirname, '../container.html')); diff --git a/packages/devextreme-scss/scss/widgets/base/chat/layout/chat-messagelist/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/chat/layout/chat-messagelist/_mixins.scss index 23064181d155..2f33b32b088c 100644 --- a/packages/devextreme-scss/scss/widgets/base/chat/layout/chat-messagelist/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/base/chat/layout/chat-messagelist/_mixins.scss @@ -8,8 +8,14 @@ $day-header-font-size, ) { .dx-chat-messagelist { - .dx-scrollable-content { - padding-inline: $padding; + > .dx-scrollable { + > .dx-scrollable-wrapper { + > .dx-scrollable-container { + > .dx-scrollable-content { + padding-inline: $padding; + } + } + } } } @@ -63,3 +69,20 @@ color: $messagelist-empty-prompt-color; } } + +@mixin chat-messagelist-contextmenu( + $delete-button-color, + $delete-button-focused-color, + $delete-button-focused-bg, +) { + .dx-messagelist-context-menu-content { + .dx-menu-item:has(.dx-icon-trash) { + color: $delete-button-color; + + &.dx-state-focused { + color: $delete-button-focused-color; + background-color: $delete-button-focused-bg; + } + } + } +} diff --git a/packages/devextreme-scss/scss/widgets/fluent/chat/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent/chat/_colors.scss index e7f2cded30f7..c93b7cd90fdf 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/chat/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/chat/_colors.scss @@ -166,3 +166,7 @@ $chat-typingindicator-circle-bg-color: null !default; * $type color */ $chat-typingindicator-bubble-bg-color: $chat-bubble-background-color-secondary !default; + +$chat-messagelist-contextmenu-delete-button-color: $base-danger !default; +$chat-messagelist-contextmenu-delete-button-focused-color: $base-danger !default; +$chat-messagelist-contextmenu-delete-button-focused-bg: $base-hover-bg !default; diff --git a/packages/devextreme-scss/scss/widgets/fluent/chat/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/chat/_index.scss index c801602bc22c..55ecec59bbe9 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/chat/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/chat/_index.scss @@ -69,6 +69,11 @@ $chat-messagelist-day-header-first-padding-top, $chat-messagelist-day-header-font-size, ); +@include chat-messagelist-contextmenu( + $chat-messagelist-contextmenu-delete-button-color, + $chat-messagelist-contextmenu-delete-button-focused-color, + $chat-messagelist-contextmenu-delete-button-focused-bg, +); @include chat-typingindicator( $chat-typingindicator-template, $chat-typingindicator-padding, diff --git a/packages/devextreme-scss/scss/widgets/generic/chat/_colors.scss b/packages/devextreme-scss/scss/widgets/generic/chat/_colors.scss index 43f13cb02bc5..5f18603ecdc5 100644 --- a/packages/devextreme-scss/scss/widgets/generic/chat/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/generic/chat/_colors.scss @@ -284,3 +284,7 @@ $chat-typingindicator-circle-bg-color: null !default; * $type color */ $chat-typingindicator-bubble-bg-color: $chat-bubble-background-color-secondary !default; + +$chat-messagelist-contextmenu-delete-button-color: $base-danger !default; +$chat-messagelist-contextmenu-delete-button-focused-color: $base-inverted-text-color !default; +$chat-messagelist-contextmenu-delete-button-focused-bg: $base-danger !default; diff --git a/packages/devextreme-scss/scss/widgets/generic/chat/_index.scss b/packages/devextreme-scss/scss/widgets/generic/chat/_index.scss index 9569a3530872..416cbce6d6c1 100644 --- a/packages/devextreme-scss/scss/widgets/generic/chat/_index.scss +++ b/packages/devextreme-scss/scss/widgets/generic/chat/_index.scss @@ -71,6 +71,11 @@ $chat-messagelist-day-header-first-padding-top, $chat-messagelist-day-header-font-size, ); +@include chat-messagelist-contextmenu( + $chat-messagelist-contextmenu-delete-button-color, + $chat-messagelist-contextmenu-delete-button-focused-color, + $chat-messagelist-contextmenu-delete-button-focused-bg, +); @include chat-typingindicator( $chat-typingindicator-template, $chat-typingindicator-padding, diff --git a/packages/devextreme-scss/scss/widgets/material/chat/_colors.scss b/packages/devextreme-scss/scss/widgets/material/chat/_colors.scss index 22824e9ba88e..64d9b10fbc89 100644 --- a/packages/devextreme-scss/scss/widgets/material/chat/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/material/chat/_colors.scss @@ -131,3 +131,7 @@ $chat-typingindicator-circle-bg-color: rgba($base-inverted-bg, 0.4) !default; @else if $mode == "dark" { $chat-bubble-background-color-primary: rgba(lighten($base-accent, 19.22), 0.08) !default; } + +$chat-messagelist-contextmenu-delete-button-color: $base-danger !default; +$chat-messagelist-contextmenu-delete-button-focused-color: $base-danger !default; +$chat-messagelist-contextmenu-delete-button-focused-bg: $base-hover-bg !default; diff --git a/packages/devextreme-scss/scss/widgets/material/chat/_index.scss b/packages/devextreme-scss/scss/widgets/material/chat/_index.scss index c6f29068ddbe..88d05e902bfa 100644 --- a/packages/devextreme-scss/scss/widgets/material/chat/_index.scss +++ b/packages/devextreme-scss/scss/widgets/material/chat/_index.scss @@ -70,6 +70,11 @@ $chat-messagelist-day-header-first-padding-top, $chat-messagelist-day-header-font-size, ); +@include chat-messagelist-contextmenu( + $chat-messagelist-contextmenu-delete-button-color, + $chat-messagelist-contextmenu-delete-button-focused-color, + $chat-messagelist-contextmenu-delete-button-focused-bg, +); @include chat-typingindicator( $chat-typingindicator-template, $chat-typingindicator-padding, diff --git a/packages/devextreme/js/__internal/ui/chat/chat.ts b/packages/devextreme/js/__internal/ui/chat/chat.ts index e7f5163fb6cd..c639b8b3c052 100644 --- a/packages/devextreme/js/__internal/ui/chat/chat.ts +++ b/packages/devextreme/js/__internal/ui/chat/chat.ts @@ -1,4 +1,5 @@ import { Guid } from '@js/common'; +import type { AsyncCancelable, EventInfo } from '@js/common/core/events'; import messageLocalization from '@js/common/core/localization/message'; import type { DataSourceOptions } from '@js/common/data'; import registerComponent from '@js/core/component_registrator'; @@ -22,14 +23,40 @@ import type { TypingStartEvent as MessageBoxTypingStartEvent, } from '@ts/ui/chat/messagebox'; import MessageBox from '@ts/ui/chat/messagebox'; -import type { MessageTemplate, Properties as MessageListProperties } from '@ts/ui/chat/messagelist'; +import type { + MessageEditingEvent, + MessageTemplate, + Properties as MessageListProperties, +} from '@ts/ui/chat/messagelist'; import MessageList from '@ts/ui/chat/messagelist'; import type { DataChange } from '@ts/ui/collection/collection_widget.base'; const CHAT_CLASS = 'dx-chat'; const TEXTEDITOR_INPUT_CLASS = 'dx-texteditor-input'; -class Chat extends Widget { +export type MessageDeletingEvent = AsyncCancelable & EventInfo & { + readonly message: Message; +}; + +export type MessageEditingStartEvent = AsyncCancelable & EventInfo & { + readonly message: Message; +}; +export interface ChatProperties extends Properties { + editing: { + allowUpdating: boolean | ((options: { + component: Chat; + message: Message; + }) => boolean); + allowDeleting: boolean | ((options: { + component: Chat; + message: Message; + }) => boolean); + }; + onMessageEditingStart?: (e: MessageEditingStartEvent) => void; + onMessageDeleting?: (e: MessageEditingStartEvent) => void; +} + +class Chat extends Widget { _messageBox!: MessageBox; _messageList!: MessageList; @@ -42,11 +69,19 @@ class Chat extends Widget { _typingEndAction?: (e: Partial) => void; - _getDefaultOptions(): Properties { + _messageEditingStartAction?: (e: Partial) => void; + + _messageDeletingAction?: (e: Partial) => void; + + _getDefaultOptions(): ChatProperties { return { ...super._getDefaultOptions(), showDayHeaders: true, activeStateEnabled: true, + editing: { + allowUpdating: false, + allowDeleting: false, + }, focusStateEnabled: true, hoverStateEnabled: true, items: [], @@ -76,6 +111,8 @@ class Chat extends Widget { this._refreshDataSource(); this._createMessageEnteredAction(); + this._createMessageEditingStartAction(); + this._createMessageDeletingAction(); this._createTypingStartAction(); this._createTypingEndAction(); } @@ -153,6 +190,8 @@ class Chat extends Widget { const options: MessageListProperties = { items, currentUserId, + allowUpdating: (message: Message): boolean => this._allowEditAction(message), + allowDeleting: (message: Message): boolean => this._allowDeleteAction(message), messageTemplate: this._getMessageTemplate(), showDayHeaders, showAvatar, @@ -162,11 +201,46 @@ class Chat extends Widget { messageTimestampFormat, typingUsers, isLoading, + onMessageEditingStart: (e) => { + this._messageEditingStartHandler(e); + }, + onMessageDeleting: (e) => { + this._messageDeletingHandler(e); + }, + onKeyHandled: () => { + this.focus(); + }, }; return options; } + protected _allowEditAction(message: Message): boolean { + const { editing } = this.option(); + const { allowUpdating } = editing; + + if (typeof allowUpdating === 'function') { + return allowUpdating({ + component: this, + message, + }); + } + return allowUpdating; + } + + protected _allowDeleteAction(message: Message): boolean { + const { editing } = this.option(); + const { allowDeleting } = editing; + + if (typeof allowDeleting === 'function') { + return allowDeleting({ + component: this, + message, + }); + } + return allowDeleting; + } + _getMessageTemplate(): MessageTemplate { const { messageTemplate } = this.option(); if (messageTemplate) { @@ -186,6 +260,18 @@ class Chat extends Widget { return null; } + _messageEditingStartHandler(e: MessageEditingEvent): void { + const { message, event } = e; + + this._messageEditingStartAction?.({ message, event }); + } + + _messageDeletingHandler(e: MessageEditingEvent): void { + const { message, event } = e; + + this._messageDeletingAction?.({ message, event }); + } + _renderAlertList(): void { const $errors = $('
'); @@ -249,6 +335,20 @@ class Chat extends Widget { ); } + _createMessageEditingStartAction(): void { + this._messageEditingStartAction = this._createActionByOption( + 'onMessageEditingStart', + { excludeValidators: ['disabled'] }, + ); + } + + _createMessageDeletingAction(): void { + this._messageDeletingAction = this._createActionByOption( + 'onMessageDeleting', + { excludeValidators: ['disabled'] }, + ); + } + _createTypingStartAction(): void { this._typingStartAction = this._createActionByOption( 'onTypingStart', @@ -308,7 +408,7 @@ class Chat extends Widget { return $input; } - _optionChanged(args: OptionChanged): void { + _optionChanged(args: OptionChanged): void { const { name, value } = args; switch (name) { @@ -323,6 +423,8 @@ class Chat extends Widget { this._messageList.option('currentUserId', author?.id); break; } + case 'editing': + break; case 'items': this._messageList.option(name, value); this._updateMessageBoxAria(); @@ -337,6 +439,12 @@ class Chat extends Widget { case 'onMessageEntered': this._createMessageEnteredAction(); break; + case 'onMessageEditingStart': + this._createMessageEditingStartAction(); + break; + case 'onMessageDeleting': + this._createMessageDeletingAction(); + break; case 'onTypingStart': this._createTypingStartAction(); break; diff --git a/packages/devextreme/js/__internal/ui/chat/messagelist.ts b/packages/devextreme/js/__internal/ui/chat/messagelist.ts index f34bc46093b9..d43e11a03dd1 100644 --- a/packages/devextreme/js/__internal/ui/chat/messagelist.ts +++ b/packages/devextreme/js/__internal/ui/chat/messagelist.ts @@ -1,4 +1,5 @@ import { Guid } from '@js/common'; +import type { Cancelable, EventInfo, NativeEventInfo } from '@js/common/core/events'; import type { Format } from '@js/common/core/localization'; import dateLocalization from '@js/common/core/localization/date'; import messageLocalization from '@js/common/core/localization/message'; @@ -11,11 +12,18 @@ import dateSerialization from '@js/core/utils/date_serialization'; import { isElementInDom } from '@js/core/utils/dom'; import { getHeight } from '@js/core/utils/size'; import { isDate, isDefined } from '@js/core/utils/type'; +import type { DxEvent } from '@js/events'; import type { Message, User } from '@js/ui/chat'; -import ScrollView from '@js/ui/scroll_view'; +import type { Item as ContextMenuItem } from '@js/ui/context_menu'; import type { WidgetOptions } from '@js/ui/widget/ui.widget'; import type { OptionChanged } from '@ts/core/widget/types'; import Widget from '@ts/core/widget/widget'; +import ContextMenu from '@ts/ui/context_menu/m_context_menu'; +import type { + ScrollView as ScrollViewType, + ScrollViewServerSide as ScrollViewServerSideType, +} from '@ts/ui/scroll_view/m_scroll_view'; +import ScrollView from '@ts/ui/scroll_view/m_scroll_view'; import { getScrollTopMax } from '@ts/ui/scroll_view/utils/get_scroll_top_max'; import type { DataChange } from '../collection/collection_widget.base'; @@ -44,12 +52,31 @@ const CHAT_MESSAGELIST_DAY_HEADER_CLASS = 'dx-chat-messagelist-day-header'; const CHAT_LAST_MESSAGEGROUP_ALIGNMENT_START_CLASS = 'dx-chat-last-messagegroup-alignment-start'; const CHAT_LAST_MESSAGEGROUP_ALIGNMENT_END_CLASS = 'dx-chat-last-messagegroup-alignment-end'; +export const CHAT_MESSAGELIST_CONTEXT_MENU_CLASS = 'dx-messagelist-context-menu'; +export const CHAT_MESSAGELIST_CONTEXT_MENU_CONTENT_CLASS = 'dx-messagelist-context-menu-content'; +export const CHAT_MESSAGELIST_CONTEXT_MENU_TARGET = `.${CHAT_MESSAGEGROUP_ALIGNMENT_END_CLASS} .${CHAT_MESSAGEBUBBLE_CLASS}`; + const SCROLLABLE_CONTAINER_CLASS = 'dx-scrollable-container'; +const ESCAPE_KEY = 'escape'; + export const MESSAGEGROUP_TIMEOUT = 5 * 1000 * 60; export type MessageTemplate = ((data: Message, messageBubbleContainer: Element) => void) | null; + +export type ItemClick = NativeEventInfo & { + readonly itemData?: ContextMenuItem; + readonly itemElement: dxElementWrapper; +}; + +export interface MessageEditingEvent { + event: DxEvent | undefined; + message: Message; +} + export interface Properties extends WidgetOptions { items: Message[]; + allowUpdating: ((message: Message) => boolean); + allowDeleting: ((message: Message) => boolean); currentUserId: number | string | undefined; showDayHeaders: boolean; messageTemplate?: MessageTemplate; @@ -60,6 +87,9 @@ export interface Properties extends WidgetOptions { showAvatar: boolean; showUserName: boolean; showMessageTimestamp: boolean; + onMessageEditingStart?: (e: MessageEditingEvent) => void; + onMessageDeleting?: (e: MessageEditingEvent) => void; + onKeyHandled?: (e: KeyboardEvent) => void; } class MessageList extends Widget { @@ -69,15 +99,19 @@ class MessageList extends Widget { private _isBottomReached!: boolean; - private _scrollView!: ScrollView; + private _scrollView!: ScrollViewType | ScrollViewServerSideType; private _typingIndicator!: TypingIndicator; + private _contextMenu!: ContextMenu; + private _$content!: dxElementWrapper; _getDefaultOptions(): Properties { return { ...super._getDefaultOptions(), + allowUpdating: () => false, + allowDeleting: () => false, items: [], currentUserId: '', showDayHeaders: true, @@ -108,6 +142,7 @@ class MessageList extends Widget { this._toggleEmptyView(); this._renderMessageGroups(); this._renderTypingIndicator(); + this._renderContextMenu(); this._updateAria(); this._scrollDownContent(); @@ -227,6 +262,85 @@ class MessageList extends Widget { }); } + _getContextMenuButtons(message: Message): ContextMenuItem[] { + const { + allowUpdating, + allowDeleting, + onMessageEditingStart, + onMessageDeleting, + } = this.option(); + + const editText = messageLocalization.format('dxChat-editingEditMessage'); + const deleteText = messageLocalization.format('dxChat-editingDeleteMessage'); + + const buttons: ContextMenuItem[] = []; + + if (allowUpdating(message)) { + buttons.push({ + icon: 'edit', + text: editText, + onClick(e: ItemClick): void { + onMessageEditingStart?.({ event: e.event, message }); + }, + }); + } + + if (allowDeleting(message)) { + buttons.push({ + icon: 'trash', + text: deleteText, + onClick(e: ItemClick): void { + onMessageDeleting?.({ event: e.event, message }); + }, + }); + } + + return buttons; + } + + _renderContextMenu(): void { + const $contextMenu = $('
'); + this._contextMenu = this._createComponent($contextMenu, ContextMenu, { + target: CHAT_MESSAGELIST_CONTEXT_MENU_TARGET, + onShowing: (e) => { + this._onContextMenuShowing(e); + }, + elementAttr: { + class: CHAT_MESSAGELIST_CONTEXT_MENU_CLASS, + }, + cssClass: CHAT_MESSAGELIST_CONTEXT_MENU_CONTENT_CLASS, + hideOnParentScroll: false, + overlayContainer: this._scrollView.content(), + visualContainer: this._scrollView.container(), + boundaryOffset: { h: 16 }, + }); + + this._contextMenu.registerKeyHandler(ESCAPE_KEY, (event: KeyboardEvent) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._contextMenu.hide(); + + const { onKeyHandled } = this.option(); + onKeyHandled?.(event); + }); + + $contextMenu.appendTo(this.$element()); + } + + _onContextMenuShowing(e: Cancelable & EventInfo): void { + // @ts-expect-error ts-error + const { currentTarget } = e.jQEvent; + + const message = this._getMessageData(currentTarget); + + const items = this._getContextMenuButtons(message); + + if (!items.length) { + e.cancel = true; + } + + e.component.option('items', items); + } + _renderScrollView(): void { const $scrollable = $('
') .appendTo(this.$element()); @@ -286,7 +400,6 @@ class MessageList extends Widget { this.$element().toggleClass(CHAT_MESSAGELIST_EMPTY_LOADING_CLASS, this._isEmpty() && isLoading); - // eslint-disable-next-line @typescript-eslint/no-floating-promises this._scrollView.release(!isLoading); } @@ -585,12 +698,10 @@ class MessageList extends Widget { } _setIsReachedBottom(): void { - // @ts-expect-error this._isBottomReached = !this._isContentOverflowing() || this._scrollView.isBottomReached(); } _isContentOverflowing(): boolean { - // @ts-expect-error return getHeight(this._scrollView.content()) > getHeight(this._scrollView.container()); } diff --git a/packages/devextreme/js/__internal/ui/context_menu/m_context_menu.ts b/packages/devextreme/js/__internal/ui/context_menu/m_context_menu.ts index f971d8624629..98a41f436562 100644 --- a/packages/devextreme/js/__internal/ui/context_menu/m_context_menu.ts +++ b/packages/devextreme/js/__internal/ui/context_menu/m_context_menu.ts @@ -31,13 +31,13 @@ import { current as currentTheme, isGeneric } from '@js/ui/themes'; import MenuBase from '@ts/ui/context_menu/m_menu_base'; const DX_MENU_CLASS = 'dx-menu'; -const DX_MENU_ITEM_CLASS = `${DX_MENU_CLASS}-item`; +export const DX_MENU_ITEM_CLASS = `${DX_MENU_CLASS}-item`; const DX_MENU_ITEM_EXPANDED_CLASS = `${DX_MENU_ITEM_CLASS}-expanded`; const DX_MENU_PHONE_CLASS = 'dx-menu-phone-overlay'; const DX_MENU_ITEMS_CONTAINER_CLASS = `${DX_MENU_CLASS}-items-container`; const DX_MENU_ITEM_WRAPPER_CLASS = `${DX_MENU_ITEM_CLASS}-wrapper`; const DX_SUBMENU_CLASS = 'dx-submenu'; -const DX_CONTEXT_MENU_CLASS = 'dx-context-menu'; +export const DX_CONTEXT_MENU_CLASS = 'dx-context-menu'; const DX_HAS_CONTEXT_MENU_CLASS = 'dx-has-context-menu'; const DX_STATE_DISABLED_CLASS = 'dx-state-disabled'; const DX_STATE_FOCUSED_CLASS = 'dx-state-focused'; @@ -121,6 +121,8 @@ class ContextMenu extends MenuBase { onLeftLastItem: null, onCloseRootSubmenu: null, onExpandLastSubmenu: null, + hideOnParentScroll: true, + visualContainer: window, }); } @@ -553,8 +555,9 @@ class ContextMenu extends MenuBase { innerOverlay: true, hideOnOutsideClick: (e) => this._hideOnOutsideClickHandler(e), propagateOutsideClick: true, - hideOnParentScroll: true, + hideOnParentScroll: this.option('hideOnParentScroll'), deferRendering: false, + container: this.option('overlayContainer'), position: { // @ts-expect-error at: position.at, @@ -562,6 +565,8 @@ class ContextMenu extends MenuBase { my: position.my, of: this._getTarget(), collision: 'flipfit', + boundary: this.option('visualContainer'), + boundaryOffset: this.option('boundaryOffset'), }, shading: false, showTitle: false, @@ -570,7 +575,7 @@ class ContextMenu extends MenuBase { onShown: this._overlayShownActionHandler.bind(this), onHiding: this._overlayHidingActionHandler.bind(this), onHidden: this._overlayHiddenActionHandler.bind(this), - visualContainer: window, + visualContainer: this.option('visualContainer'), }; // @ts-expect-error return overlayOptions; @@ -974,6 +979,8 @@ class ContextMenu extends MenuBase { break; case 'closeOnOutsideClick': case 'hideOnOutsideClick': + case 'hideOnParentScroll': + case 'visualContainer': break; default: super._optionChanged(args); diff --git a/packages/devextreme/js/localization/messages/ar.json b/packages/devextreme/js/localization/messages/ar.json index 285a6e7cbb00..203e28dfd856 100644 --- a/packages/devextreme/js/localization/messages/ar.json +++ b/packages/devextreme/js/localization/messages/ar.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "تعديل", + "dxChat-editingDeleteMessage": "حذف", "dxColorView-ariaRed": "أحمر", "dxColorView-ariaGreen": "أخضر", diff --git a/packages/devextreme/js/localization/messages/bg.json b/packages/devextreme/js/localization/messages/bg.json index 9624006ac4e8..5d006cf874b8 100644 --- a/packages/devextreme/js/localization/messages/bg.json +++ b/packages/devextreme/js/localization/messages/bg.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Редактиране", + "dxChat-editingDeleteMessage": "Изтриване", "dxColorView-ariaRed": "Red", "dxColorView-ariaGreen": "Green", diff --git a/packages/devextreme/js/localization/messages/ca.json b/packages/devextreme/js/localization/messages/ca.json index 0ed1c8c2c658..c976247c84d0 100644 --- a/packages/devextreme/js/localization/messages/ca.json +++ b/packages/devextreme/js/localization/messages/ca.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Preprarar una edició", + "dxChat-editingDeleteMessage": "Esborrar", "dxColorView-ariaRed": "Vermell", "dxColorView-ariaGreen": "Verd", diff --git a/packages/devextreme/js/localization/messages/cs.json b/packages/devextreme/js/localization/messages/cs.json index 4dd87627ca5f..a3f2833cd53f 100644 --- a/packages/devextreme/js/localization/messages/cs.json +++ b/packages/devextreme/js/localization/messages/cs.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Upravit", + "dxChat-editingDeleteMessage": "Smazat", "dxColorView-ariaRed": "Červená", "dxColorView-ariaGreen": "Zelená", diff --git a/packages/devextreme/js/localization/messages/da.json b/packages/devextreme/js/localization/messages/da.json index 67ccaffdf5d7..2dc9dadbde04 100644 --- a/packages/devextreme/js/localization/messages/da.json +++ b/packages/devextreme/js/localization/messages/da.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} og {1} skriver...", "dxChat-typingMessageThreeUsers": "{0}, {1} og {2} skriver...", "dxChat-typingMessageMultipleUsers": "{0} og andre skriver...", + "dxChat-editingEditMessage": "Rediger", + "dxChat-editingDeleteMessage": "Slet", "dxColorView-ariaRed": "Rød", "dxColorView-ariaGreen": "Grøn", diff --git a/packages/devextreme/js/localization/messages/de.json b/packages/devextreme/js/localization/messages/de.json index c1ac8dedcc33..60ff8b6b8fcc 100644 --- a/packages/devextreme/js/localization/messages/de.json +++ b/packages/devextreme/js/localization/messages/de.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Bearbeiten", + "dxChat-editingDeleteMessage": "Entfernen", "dxColorView-ariaRed": "Rot", "dxColorView-ariaGreen": "Grün", diff --git a/packages/devextreme/js/localization/messages/el.json b/packages/devextreme/js/localization/messages/el.json index bf4727fcf47c..24f03bfb251b 100644 --- a/packages/devextreme/js/localization/messages/el.json +++ b/packages/devextreme/js/localization/messages/el.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Επεξεργασία", + "dxChat-editingDeleteMessage": "Διαγραφή", "dxColorView-ariaRed": "Κόκκινο", "dxColorView-ariaGreen": "Πράσινο", diff --git a/packages/devextreme/js/localization/messages/en.json b/packages/devextreme/js/localization/messages/en.json index fac82340b813..5e47b58417aa 100644 --- a/packages/devextreme/js/localization/messages/en.json +++ b/packages/devextreme/js/localization/messages/en.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Edit", + "dxChat-editingDeleteMessage": "Delete", "dxColorView-ariaRed": "Red", "dxColorView-ariaGreen": "Green", diff --git a/packages/devextreme/js/localization/messages/es.json b/packages/devextreme/js/localization/messages/es.json index 3c48b61e3d5b..54405e928b1e 100644 --- a/packages/devextreme/js/localization/messages/es.json +++ b/packages/devextreme/js/localization/messages/es.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Modificar", + "dxChat-editingDeleteMessage": "Eliminar", "dxColorView-ariaRed": "Rojo", "dxColorView-ariaGreen": "Verde", diff --git a/packages/devextreme/js/localization/messages/fa.json b/packages/devextreme/js/localization/messages/fa.json index 9d5a5bc7bbb1..af583ff93363 100644 --- a/packages/devextreme/js/localization/messages/fa.json +++ b/packages/devextreme/js/localization/messages/fa.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "ویرایش", + "dxChat-editingDeleteMessage": "حذف", "dxColorView-ariaRed": "قرمز", "dxColorView-ariaGreen": "سبز", diff --git a/packages/devextreme/js/localization/messages/fi.json b/packages/devextreme/js/localization/messages/fi.json index 9e6bcafbf5d4..b6c317669d5a 100644 --- a/packages/devextreme/js/localization/messages/fi.json +++ b/packages/devextreme/js/localization/messages/fi.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Muokkaa", + "dxChat-editingDeleteMessage": "Poista", "dxColorView-ariaRed": "Punainen", "dxColorView-ariaGreen": "Vihreä", diff --git a/packages/devextreme/js/localization/messages/fr.json b/packages/devextreme/js/localization/messages/fr.json index 2ef9b9f455a6..db7ddc06cf25 100644 --- a/packages/devextreme/js/localization/messages/fr.json +++ b/packages/devextreme/js/localization/messages/fr.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Editer", + "dxChat-editingDeleteMessage": "Supprimer", "dxColorView-ariaRed": "Rouge", "dxColorView-ariaGreen": "Vert", diff --git a/packages/devextreme/js/localization/messages/hu.json b/packages/devextreme/js/localization/messages/hu.json index 9d280e672243..37f2ed161e80 100644 --- a/packages/devextreme/js/localization/messages/hu.json +++ b/packages/devextreme/js/localization/messages/hu.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Szerkesztés", + "dxChat-editingDeleteMessage": "Törlés", "dxColorView-ariaRed": "Piros", "dxColorView-ariaGreen": "Zöld", diff --git a/packages/devextreme/js/localization/messages/it.json b/packages/devextreme/js/localization/messages/it.json index d3b38099cd9f..52ec8574571f 100644 --- a/packages/devextreme/js/localization/messages/it.json +++ b/packages/devextreme/js/localization/messages/it.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Modifica", + "dxChat-editingDeleteMessage": "Elimina", "dxColorView-ariaRed": "Rosso", "dxColorView-ariaGreen": "Verde", diff --git a/packages/devextreme/js/localization/messages/ja.json b/packages/devextreme/js/localization/messages/ja.json index 484bf85be112..400d4ec7781a 100644 --- a/packages/devextreme/js/localization/messages/ja.json +++ b/packages/devextreme/js/localization/messages/ja.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "編集", + "dxChat-editingDeleteMessage": "削除", "dxColorView-ariaRed": "赤", "dxColorView-ariaGreen": "緑", diff --git a/packages/devextreme/js/localization/messages/lt.json b/packages/devextreme/js/localization/messages/lt.json index 4afec386eebc..b5a1219798a2 100644 --- a/packages/devextreme/js/localization/messages/lt.json +++ b/packages/devextreme/js/localization/messages/lt.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Redaguoti", + "dxChat-editingDeleteMessage": "Ištrinti", "dxColorView-ariaRed": "Raudona", "dxColorView-ariaGreen": "Žalia", diff --git a/packages/devextreme/js/localization/messages/lv.json b/packages/devextreme/js/localization/messages/lv.json index e81cab382fa2..e88b1f46a522 100644 --- a/packages/devextreme/js/localization/messages/lv.json +++ b/packages/devextreme/js/localization/messages/lv.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Rediģēt", + "dxChat-editingDeleteMessage": "Dzēst", "dxColorView-ariaRed": "Sarkans", "dxColorView-ariaGreen": "Zaļš", diff --git a/packages/devextreme/js/localization/messages/nb.json b/packages/devextreme/js/localization/messages/nb.json index f834671cb1fb..cd42b8a1540f 100644 --- a/packages/devextreme/js/localization/messages/nb.json +++ b/packages/devextreme/js/localization/messages/nb.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Endre", + "dxChat-editingDeleteMessage": "Slett", "dxColorView-ariaRed": "Rød", "dxColorView-ariaGreen": "Grønn", diff --git a/packages/devextreme/js/localization/messages/nl.json b/packages/devextreme/js/localization/messages/nl.json index 4ca4ccc97d4a..91098091e81e 100644 --- a/packages/devextreme/js/localization/messages/nl.json +++ b/packages/devextreme/js/localization/messages/nl.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Wijzigen", + "dxChat-editingDeleteMessage": "Verwijderen", "dxColorView-ariaRed": "Rood", "dxColorView-ariaGreen": "Groen", diff --git a/packages/devextreme/js/localization/messages/pl.json b/packages/devextreme/js/localization/messages/pl.json index 588f08e67b21..11b2251e2514 100644 --- a/packages/devextreme/js/localization/messages/pl.json +++ b/packages/devextreme/js/localization/messages/pl.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Edytuj", + "dxChat-editingDeleteMessage": "Usuń", "dxColorView-ariaRed": "czerwony", "dxColorView-ariaGreen": "zielony", diff --git a/packages/devextreme/js/localization/messages/pt.json b/packages/devextreme/js/localization/messages/pt.json index 065f286e82b1..661edfe8bfc5 100644 --- a/packages/devextreme/js/localization/messages/pt.json +++ b/packages/devextreme/js/localization/messages/pt.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} e {1} estão digitando...", "dxChat-typingMessageThreeUsers": "{0}, {1} e {2} estão digitando...", "dxChat-typingMessageMultipleUsers": "{0} e outros estão digitando...", + "dxChat-editingEditMessage": "Editar", + "dxChat-editingDeleteMessage": "Eliminar", "dxColorView-ariaRed": "Vermelho", "dxColorView-ariaGreen": "Verde", diff --git a/packages/devextreme/js/localization/messages/ro.json b/packages/devextreme/js/localization/messages/ro.json index 42a397993e37..8000185927ad 100644 --- a/packages/devextreme/js/localization/messages/ro.json +++ b/packages/devextreme/js/localization/messages/ro.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Editare", + "dxChat-editingDeleteMessage": "Șterge", "dxColorView-ariaRed": "Roșu", "dxColorView-ariaGreen": "Verde", diff --git a/packages/devextreme/js/localization/messages/ru.json b/packages/devextreme/js/localization/messages/ru.json index f994ce0262ad..308a0c6abd1a 100644 --- a/packages/devextreme/js/localization/messages/ru.json +++ b/packages/devextreme/js/localization/messages/ru.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Редактировать", + "dxChat-editingDeleteMessage": "Удалить", "dxColorView-ariaRed": "Красный", "dxColorView-ariaGreen": "Зеленый", diff --git a/packages/devextreme/js/localization/messages/sl.json b/packages/devextreme/js/localization/messages/sl.json index 9aa43730ce60..68ee34b154dc 100644 --- a/packages/devextreme/js/localization/messages/sl.json +++ b/packages/devextreme/js/localization/messages/sl.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Uredi", + "dxChat-editingDeleteMessage": "Briši", "dxColorView-ariaRed": "Rdeča", "dxColorView-ariaGreen": "Zelena", diff --git a/packages/devextreme/js/localization/messages/sv.json b/packages/devextreme/js/localization/messages/sv.json index b01622cd90c5..e5d18208b278 100644 --- a/packages/devextreme/js/localization/messages/sv.json +++ b/packages/devextreme/js/localization/messages/sv.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Redigera", + "dxChat-editingDeleteMessage": "Radera", "dxColorView-ariaRed": "Röd", "dxColorView-ariaGreen": "Grön", diff --git a/packages/devextreme/js/localization/messages/tr.json b/packages/devextreme/js/localization/messages/tr.json index aedf5fed6132..0c1e53188f0c 100644 --- a/packages/devextreme/js/localization/messages/tr.json +++ b/packages/devextreme/js/localization/messages/tr.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Düzenle", + "dxChat-editingDeleteMessage": "Sil", "dxColorView-ariaRed": "Kırmızı", "dxColorView-ariaGreen": "Yeşil", diff --git a/packages/devextreme/js/localization/messages/uk.json b/packages/devextreme/js/localization/messages/uk.json index 4cf95002d28e..925cbd903afe 100644 --- a/packages/devextreme/js/localization/messages/uk.json +++ b/packages/devextreme/js/localization/messages/uk.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Редагувати", + "dxChat-editingDeleteMessage": "Видалити", "dxColorView-ariaRed": "Red", "dxColorView-ariaGreen": "Green", diff --git a/packages/devextreme/js/localization/messages/vi.json b/packages/devextreme/js/localization/messages/vi.json index c76ddb4e5398..997e9e64472c 100644 --- a/packages/devextreme/js/localization/messages/vi.json +++ b/packages/devextreme/js/localization/messages/vi.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "Sửa", + "dxChat-editingDeleteMessage": "Xóa", "dxColorView-ariaRed": "Đỏ", "dxColorView-ariaGreen": "Xanh lá", diff --git a/packages/devextreme/js/localization/messages/zh-tw.json b/packages/devextreme/js/localization/messages/zh-tw.json index 3ac7a229942b..ce749872e472 100644 --- a/packages/devextreme/js/localization/messages/zh-tw.json +++ b/packages/devextreme/js/localization/messages/zh-tw.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "編輯", + "dxChat-editingDeleteMessage": "刪除", "dxColorView-ariaRed": "紅色", "dxColorView-ariaGreen": "綠色", diff --git a/packages/devextreme/js/localization/messages/zh.json b/packages/devextreme/js/localization/messages/zh.json index c403fb566400..789cd27f56d6 100644 --- a/packages/devextreme/js/localization/messages/zh.json +++ b/packages/devextreme/js/localization/messages/zh.json @@ -366,6 +366,8 @@ "dxChat-typingMessageTwoUsers": "{0} and {1} are typing...", "dxChat-typingMessageThreeUsers": "{0}, {1} and {2} are typing...", "dxChat-typingMessageMultipleUsers": "{0} and others are typing...", + "dxChat-editingEditMessage": "编辑", + "dxChat-editingDeleteMessage": "删除", "dxColorView-ariaRed": "红色", "dxColorView-ariaGreen": "绿色", 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 e4a5d2ca92a9..29026f652440 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 @@ -1,13 +1,22 @@ import $ from 'jquery'; import Chat from 'ui/chat'; -import MessageList from '__internal/ui/chat/messagelist'; +import MessageList, { + CHAT_MESSAGELIST_CONTEXT_MENU_CLASS +} from '__internal/ui/chat/messagelist'; +import ContextMenu, { + DX_MENU_ITEM_CLASS, +} from '__internal/ui/context_menu/m_context_menu'; +import { + FOCUSED_STATE_CLASS +} from '__internal/core/widget/widget'; import AlertList from '__internal/ui/chat/alertlist'; import MessageBox, { TYPING_END_DELAY } from '__internal/ui/chat/messagebox'; import keyboardMock from '../../../helpers/keyboardMock.js'; import { DataSource } from 'common/data/data_source/data_source'; import { CustomStore } from 'common/data/custom_store'; import dataUtils from 'core/element_data'; +import devices from '__internal/core/m_devices'; import { isRenderer } from 'core/utils/type'; @@ -86,6 +95,8 @@ const moduleConfig = { this.getDayHeaders = () => this.$element.find(`.${CHAT_MESSAGELIST_DAY_HEADER_CLASS}`); this.getBubbles = () => this.$element.find(`.${CHAT_MESSAGEBUBBLE_CLASS}`); this.getBubblesContents = () => this.$element.find(`.${CHAT_MESSAGEBUBBLE_CONTENT_CLASS}`); + this.getContextMenu = () => ContextMenu.getInstance(this.$element.find(`.${CHAT_MESSAGELIST_CONTEXT_MENU_CLASS}`)); + this.getContextMenuItems = () => $(this.getContextMenu().itemsContainer()).find(`.${DX_MENU_ITEM_CLASS}`); init(); } @@ -102,7 +113,7 @@ QUnit.module('Chat', () => { QUnit.test('user should be set to an object with generated id if property is not passed', function(assert) { const { user } = this.instance.option(); - // eslint-disable-next-line no-prototype-builtins + assert.strictEqual(user.hasOwnProperty('id'), true); }); @@ -246,6 +257,116 @@ QUnit.module('Chat', () => { }); }); + QUnit.module('Editing', () => { + ['allowDeleting', 'allowUpdating'].forEach(option => { + QUnit.test(`Chat should pass editing.${option} to messageList on init`, function(assert) { + this.reinit({ + editing: { + [option]: true + } + }); + + const messageList = this.getMessageList(); + + assert.strictEqual(messageList.option(option)(), true, `${option} is passed on init`); + }); + + QUnit.test(`Chat should pass editing.${option} as function to messageList on init`, function(assert) { + this.reinit({ + editing: { + [option]: () => { + return true; + } + } + }); + + const messageList = this.getMessageList(); + + assert.strictEqual(messageList.option(option)(), true, `${option} is passed on init`); + }); + + QUnit.test(`Chat editing.${option} should be respected by messageList after changing at runtime`, function(assert) { + this.reinit({ + editing: { + [option]: () => { + return false; + } + } + }); + + const messageList = this.getMessageList(); + + this.instance.option('editing', { + [option]: (options) => { + return options.message.id === 1; + } + }); + + assert.strictEqual(messageList.option(option)({ id: 1 }), true, `${option} is respected after change at runtime` + ); + }); + + QUnit.test(`Chat editing.${option} should receive correct arguments`, function(assert) { + assert.expect(4); + + const allowActionSpy = sinon.spy(() => true); + + const items = [ + { text: 'a', author: userFirst }, + { text: 'b', author: userSecond }, + ]; + + this.reinit({ + user: userSecond, + editing: { + [option]: allowActionSpy + }, + items, + }); + + assert.strictEqual(allowActionSpy.callCount, 0, 'allow action callback should not be called initially'); + + const $bubbles = this.getBubbles(); + $bubbles.eq(1).trigger('dxcontextmenu'); + + assert.strictEqual(allowActionSpy.callCount, 1, 'allow action callback should be called after bubble click'); + assert.strictEqual(allowActionSpy.args[0][0].component.NAME, 'dxChat', 'component is passed correctly'); + assert.deepEqual(allowActionSpy.args[0][0].message, items[1], 'target message is passed correctly'); + }); + }); + + QUnit.testInActiveWindow('Contextmenu should be hidden and input focused after esc is pressed', function(assert) { + if(devices.real().deviceType !== 'desktop') { + assert.ok(true, 'Test is not applicable for mobile devices'); + return; + } + + const items = [ + { id: '1', text: 'a', author: userFirst }, + { id: '2', text: 'b', author: userSecond }, + ]; + + this.reinit({ + focusStateEnabled: true, + items, + user: userSecond, + editing: { + allowUpdating: true, + allowDeleting: true, + } + }); + debugger; + const $bubbles = this.getBubbles(); + $bubbles.eq(1).trigger('dxcontextmenu'); + + keyboardMock(this.getContextMenu().itemsContainer()) + .keyDown('esc'); + + assert.strictEqual(this.getContextMenu().option('visible'), false, 'context menu is hidden'); + assert.strictEqual(this.$textArea.hasClass(FOCUSED_STATE_CLASS), true, 'input is focused'); + }); + }); + QUnit.module('messageTemplate', () => { QUnit.test('messageTemplate should set bubble content on init', function(assert) { const messageTemplate = (data, container) => { @@ -525,7 +646,7 @@ QUnit.module('Chat', () => { const { author, text: messageText } = message; assert.strictEqual(author, this.instance.option('user'), 'author field is correct'); - // eslint-disable-next-line no-prototype-builtins + assert.strictEqual(message.hasOwnProperty('timestamp'), true, 'timestamp field is set'); assert.strictEqual(messageText, text, 'text field is correct'); }, @@ -539,6 +660,184 @@ QUnit.module('Chat', () => { }); }); + QUnit.module('OnMessageEditingStart', moduleConfig, () => { + QUnit.test('should be called when the Edit button is clicked', function(assert) { + const onMessageEditingStart = sinon.spy(); + + const items = [ + { text: 'a', author: userFirst }, + { text: 'b', author: userSecond }, + ]; + + this.reinit({ + user: userSecond, + editing: { + allowUpdating: true + }, + onMessageEditingStart, + items, + }); + + const $bubbles = this.getBubbles(); + $bubbles.eq(1).trigger('dxcontextmenu'); + + const $editButton = this.getContextMenuItems().eq(0); + $editButton.trigger('dxclick'); + + assert.strictEqual(onMessageEditingStart.callCount, 1); + }); + + QUnit.test('should get correct arguments after clicking the Edit button', function(assert) { + assert.expect(6); + + const items = [ + { text: 'a', author: userFirst }, + { text: 'b', author: userSecond }, + ]; + + this.reinit({ + user: userSecond, + editing: { + allowUpdating: true + }, + onMessageEditingStart: (e) => { + const { component, element, event, message } = e; + + assert.strictEqual(component, this.instance, 'e.component is correct'); + assert.strictEqual(isRenderer(element), !!config().useJQuery, 'e.element uses correct renderer'); + assert.strictEqual($(element).is(this.$element), true, 'e.element matches the widget root'); + assert.strictEqual(event.type, 'dxclick', 'e.event.type is correct'); + assert.strictEqual(event.target, $editButton.get(0), 'e.event.target is correct'); + assert.strictEqual(message, items[1], 'e.message is correct'); + }, + items, + }); + + const $bubbles = this.getBubbles(); + $bubbles.eq(1).trigger('dxcontextmenu'); + + const $editButton = this.getContextMenuItems().eq(0); + $editButton.trigger('dxclick'); + }); + + QUnit.test('should allow updating onMessageEditingStart at runtime', function(assert) { + const items = [ + { text: 'a', author: userFirst }, + { text: 'b', author: userSecond }, + ]; + + this.reinit({ + user: userSecond, + editing: { + allowUpdating: true + }, + onMessageEditingStart: () => {}, + items, + }); + + const onMessageEditingStart = sinon.spy(); + + this.instance.option({ onMessageEditingStart }); + + const $bubbles = this.getBubbles(); + $bubbles.eq(1).trigger('dxcontextmenu'); + + const $editButton = this.getContextMenuItems().eq(0); + $editButton.trigger('dxclick'); + + assert.strictEqual(onMessageEditingStart.callCount, 1); + }); + }); + + QUnit.module('onMessageDeleting', moduleConfig, () => { + QUnit.test('should be called when the Edit button is clicked', function(assert) { + const onMessageDeleting = sinon.spy(); + + const items = [ + { text: 'a', author: userFirst }, + { text: 'b', author: userSecond }, + ]; + + this.reinit({ + user: userSecond, + editing: { + allowDeleting: true + }, + onMessageDeleting, + items, + }); + + const $bubbles = this.getBubbles(); + $bubbles.eq(1).trigger('dxcontextmenu'); + + const $deleteButton = this.getContextMenuItems().eq(0); + $deleteButton.trigger('dxclick'); + + assert.strictEqual(onMessageDeleting.callCount, 1); + }); + + QUnit.test('should get correct arguments after clicking the Delete button', function(assert) { + assert.expect(6); + + const items = [ + { text: 'a', author: userFirst }, + { text: 'b', author: userSecond }, + ]; + + this.reinit({ + user: userSecond, + editing: { + allowDeleting: true + }, + onMessageDeleting: (e) => { + const { component, element, event, message } = e; + + assert.strictEqual(component, this.instance, 'e.component is correct'); + assert.strictEqual(isRenderer(element), !!config().useJQuery, 'e.element uses correct renderer'); + assert.strictEqual($(element).is(this.$element), true, 'e.element matches the widget root'); + assert.strictEqual(event.type, 'dxclick', 'e.event.type is correct'); + assert.strictEqual(event.target, $deleteButton.get(0), 'e.event.target is correct'); + assert.strictEqual(message, items[1], 'e.message is correct'); + }, + items, + }); + + const $bubbles = this.getBubbles(); + $bubbles.eq(1).trigger('dxcontextmenu'); + + const $deleteButton = this.getContextMenuItems().eq(0); + $deleteButton.trigger('dxclick'); + }); + + QUnit.test('should allow updating onMessageDeleting at runtime', function(assert) { + const items = [ + { text: 'a', author: userFirst }, + { text: 'b', author: userSecond }, + ]; + + this.reinit({ + user: userSecond, + editing: { + allowDeleting: true + }, + onMessageDeleting: () => {}, + items, + }); + + const onMessageDeleting = sinon.spy(); + + this.instance.option({ onMessageDeleting }); + + const $bubbles = this.getBubbles(); + $bubbles.eq(1).trigger('dxcontextmenu'); + + const $deleteButton = this.getContextMenuItems().eq(0); + $deleteButton.trigger('dxclick'); + + assert.strictEqual(onMessageDeleting.callCount, 1); + }); + }); + QUnit.module('onTypingStart', moduleConfig, () => { QUnit.test('should be called with correct arguments', function(assert) { assert.expect(5); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js index 72b583af4dca..8f65931a6718 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js @@ -1,6 +1,11 @@ import $ from 'jquery'; -import MessageList, { MESSAGEGROUP_TIMEOUT } from '__internal/ui/chat/messagelist'; +import MessageList, { + MESSAGEGROUP_TIMEOUT, + CHAT_MESSAGELIST_CONTEXT_MENU_TARGET, + CHAT_MESSAGELIST_CONTEXT_MENU_CONTENT_CLASS, + CHAT_MESSAGELIST_CONTEXT_MENU_CLASS, +} from '__internal/ui/chat/messagelist'; import ScrollView from 'ui/scroll_view'; import { generateMessages, @@ -10,6 +15,9 @@ import { MOCK_COMPANION_USER_ID, MOCK_CURRENT_USER_ID, } from './chat.tests.js'; +import ContextMenu, { + DX_MENU_ITEM_CLASS +} from '__internal/ui/context_menu/m_context_menu'; import MessageGroup from '__internal/ui/chat/messagegroup'; import TypingIndicator from '__internal/ui/chat/typingindicator'; import devices from '__internal/core/m_devices'; @@ -49,8 +57,11 @@ const moduleConfig = { this.getDayHeaders = () => $(this.getScrollView().content()).find(`.${CHAT_MESSAGELIST_DAY_HEADER_CLASS}`); this.getMessageGroups = () => this.$element.find(`.${CHAT_MESSAGEGROUP_CLASS}`); this.getBubbles = () => this.$element.find(`.${CHAT_MESSAGEBUBBLE_CLASS}`); + this.getContextMenu = () => ContextMenu.getInstance(this.$element.find(`.${CHAT_MESSAGELIST_CONTEXT_MENU_CLASS}`)); + this.getContextMenuItems = () => $(this.getContextMenu().itemsContainer()).find(`.${DX_MENU_ITEM_CLASS}`); this.scrollView = this.getScrollView(); + this.contextMenu = this.getContextMenu(); this.$scrollViewContent = $(this.scrollView.content()); }; @@ -1095,6 +1106,216 @@ QUnit.module('MessageList', () => { }); }); + QUnit.module('ContextMenu', moduleConfig, () => { + QUnit.test('should be initialized with the correct options', function(assert) { + const expectedOptions = { + hideOnParentScroll: false, + target: CHAT_MESSAGELIST_CONTEXT_MENU_TARGET, + cssClass: CHAT_MESSAGELIST_CONTEXT_MENU_CONTENT_CLASS, + visible: false, + overlayContainer: this.getScrollView().content(), + visualContainer: this.getScrollView().container(), + }; + + Object.entries(expectedOptions).forEach(([key, value]) => { + assert.deepEqual(value, this.contextMenu.option(key), `${key} value is correct`); + }); + }); + + QUnit.test('should not be shown after right-clicking on a message from another participant', function(assert) { + this.reinit({ + allowUpdating: () => true, + allowDeleting: () => true, + items: [ + { text: 'a', author: userFirst }, + { text: 'b', author: userSecond }, + { text: 'c', author: userFirst }, + { text: 'd', author: userSecond }, + ], + currentUserId: userFirst.id, + }); + + const $bubbles = this.getBubbles(); + $bubbles.eq(1).trigger('dxcontextmenu'); + + assert.strictEqual(this.contextMenu.option('visible'), false, 'context menu is hidden'); + }); + + QUnit.test('should be shown after right-clicking on a message from the current user', function(assert) { + this.reinit({ + allowUpdating: () => true, + items: [ + { text: 'a', author: userFirst }, + { text: 'b', author: userSecond }, + { text: 'c', author: userFirst }, + { text: 'd', author: userSecond }, + ], + currentUserId: userFirst.id, + }); + + const $bubbles = this.getBubbles(); + $bubbles.eq(2).trigger('dxcontextmenu'); + + assert.strictEqual(this.contextMenu.option('visible'), true, 'context menu is visible'); + }); + + QUnit.test('should not be shown after right-clicking on a message from the current user when editing is turned off', function(assert) { + this.reinit({ + allowUpdating: () => false, + allowDeleting: () => false, + items: [ + { text: 'a', author: userFirst }, + { text: 'b', author: userSecond }, + { text: 'c', author: userFirst }, + { text: 'd', author: userSecond }, + ], + currentUserId: userFirst.id, + }); + + const $bubbles = this.getBubbles(); + $bubbles.eq(2).trigger('dxcontextmenu'); + + assert.strictEqual(this.contextMenu.option('visible'), false, 'context menu is not visible'); + }); + + QUnit.test('should contain corresponding actions with correct icons', function(assert) { + + this.reinit({ + allowDeleting: () => true, + allowUpdating: () => true, + items: [ + { text: 'a', author: userFirst }, + { text: 'b', author: userSecond }, + ], + currentUserId: userSecond.id, + }); + + const $bubbles = this.getBubbles(); + $bubbles.eq(1).trigger('dxcontextmenu'); + + const actions = this.contextMenu.option('items'); + assert.strictEqual(actions.length, 2, 'context menu contains two actions'); + assert.strictEqual(actions[0].icon, 'edit', 'Edit action has the correct icon'); + assert.strictEqual(actions[1].icon, 'trash', 'Delete action has the correct icon'); + }); + + [ + { + editingOptions: { + allowDeleting: () => false, + allowUpdating: () => false, + }, + expectedActions: [] + }, { + editingOptions: { + allowDeleting: () => false, + allowUpdating: (message) => { + return message.id === '2'; + } + }, + expectedActions: ['Edit'] + }, { + editingOptions: { allowDeleting: (message) => { + return message.id === '2'; + }, allowUpdating: () => false }, + expectedActions: ['Delete'] + }, { + editingOptions: { + allowDeleting: () => true, + allowUpdating: () => false + }, + expectedActions: ['Delete'] + }, { + editingOptions: { + allowDeleting: () => true, + allowUpdating: () => true + }, + expectedActions: ['Edit', 'Delete'] + }, + ].forEach(({ editingOptions, expectedActions }) => { + QUnit.test(`should contain corresponding actions when ${JSON.stringify(editingOptions)}`, function(assert) { + this.reinit({ + ...editingOptions, + items: [ + { id: '1', text: 'a', author: userFirst }, + { id: '2', text: 'b', author: userSecond }, + ], + currentUserId: userSecond.id, + }); + + const $bubbles = this.getBubbles(); + $bubbles.eq(1).trigger('dxcontextmenu'); + + const actions = this.contextMenu.option('items'); + assert.strictEqual(actions.length, expectedActions.length, `context menu action count: ${expectedActions.length}`); + + expectedActions.forEach((actionName, index) => { + assert.strictEqual(actions[index].text, actionName, `action text at index ${index} is correct`); + }); + }); + }); + }); + + QUnit.module('Editing', moduleConfig, () => { + QUnit.test('onMessageEditingStart should be fired when the Edit button is clicked', function(assert) { + assert.expect(4); + const items = [ + { id: '1', text: 'a', author: userFirst }, + { id: '2', text: 'b', author: userSecond }, + ]; + + this.reinit({ + allowUpdating: () => true, + items, + onMessageEditingStart(e) { + const { event, message } = e; + + assert.strictEqual(event.type, 'dxclick', 'e.event.type is correct'); + assert.strictEqual(event.target, $editButton.get(0), 'event target is correct'); + assert.deepEqual(message, items[1], 'message field is correct'); + }, + currentUserId: userSecond.id, + }); + + const $bubbles = this.getBubbles(); + $bubbles.eq(1).trigger('dxcontextmenu'); + + const $editButton = this.getContextMenuItems().eq(0); + $editButton.trigger('dxclick'); + + assert.strictEqual(this.contextMenu.option('visible'), false, 'context menu is hidden'); + }); + + QUnit.test('onMessageDeleting should be fired when the Delete button is clicked', function(assert) { + assert.expect(4); + const items = [ + { id: '1', text: 'a', author: userFirst }, + { id: '2', text: 'b', author: userSecond }, + ]; + + this.reinit({ + allowDeleting: () => true, + items, + onMessageDeleting(e) { + const { event, message } = e; + + assert.strictEqual(event.type, 'dxclick', 'e.event.type is correct'); + assert.strictEqual(event.target, $deleteButton.get(0), 'event target is correct'); + assert.deepEqual(message, items[1], 'message field is correct'); + }, + currentUserId: userSecond.id, + }); + + const $bubbles = this.getBubbles(); + $bubbles.eq(1).trigger('dxcontextmenu'); + + const $deleteButton = this.getContextMenuItems().eq(0); + $deleteButton.trigger('dxclick'); + + assert.strictEqual(this.contextMenu.option('visible'), false, 'context menu is hidden'); + }); + }); + QUnit.module('ScrollView', { beforeEach: function() { moduleConfig.beforeEach.apply(this, arguments); @@ -1634,6 +1855,43 @@ QUnit.module('MessageList', () => { localization.locale(defaultLocale); } }); + + QUnit.test('context menu button texts should match custom localized values from the dictionary', function(assert) { + const defaultLocale = localization.locale(); + + const localizedEditMessageText = '編集'; + const localizedDeleteMessageText = '削除'; + + try { + localization.loadMessages({ + 'ja': { + 'dxChat-editingEditMessage': localizedEditMessageText, + 'dxChat-editingDeleteMessage': localizedDeleteMessageText + } + }); + localization.locale('ja'); + + this.reinit({ + allowUpdating: () => true, + allowDeleting: () => true, + items: [ + { text: 'a', author: userFirst }, + { text: 'b', author: userSecond }, + ], + currentUserId: userFirst.id, + }); + + const $bubbles = this.getBubbles(); + $bubbles.eq(0).trigger('dxcontextmenu'); + + const $contextMenuItems = this.getContextMenuItems(); + + assert.strictEqual($contextMenuItems.eq(0).text(), localizedEditMessageText, 'Edit message is localized correctly'); + assert.strictEqual($contextMenuItems.eq(1).text(), localizedDeleteMessageText, 'Delete message is localized correctly'); + } finally { + localization.locale(defaultLocale); + } + }); }); }); diff --git a/packages/devextreme/testing/tests/DevExpress.ui/defaultOptions.tests.js b/packages/devextreme/testing/tests/DevExpress.ui/defaultOptions.tests.js index d1a4db288638..df41a46fefac 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui/defaultOptions.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui/defaultOptions.tests.js @@ -1377,6 +1377,10 @@ testComponentDefaults(Chat, onMessageEntered: undefined, onTypingStart: undefined, onTypingEnd: undefined, + editing: { + allowUpdating: false, + allowDeleting: false, + } } ); @@ -1426,6 +1430,8 @@ testComponentDefaults(ChatMessageList, isLoading: false, dayHeaderFormat: 'shortdate', messageTimestampFormat: 'shorttime', + allowUpdating: false, + allowDeleting: false, } ); diff --git a/packages/testcafe-models/chat.ts b/packages/testcafe-models/chat.ts index fb6d1f9bc118..f5d28c29df55 100644 --- a/packages/testcafe-models/chat.ts +++ b/packages/testcafe-models/chat.ts @@ -10,6 +10,7 @@ const CLASS = { messageBoxButton: 'dx-chat-messagebox-button', scrollable: 'dx-scrollable', textArea: 'dx-textarea', + messageBubble: 'dx-chat-messagebubble' }; export default class Chat extends Widget { @@ -37,6 +38,10 @@ export default class Chat extends Widget { } getScrollable(): Scrollable { - return new Scrollable(this.element.find(`.${CLASS.scrollable}`)); + return new Scrollable(this.element.find(`.${CLASS.scrollable}`)); + } + + getMessage(index: number): Selector { + return this.element.find(`.${CLASS.messageBubble}`).nth(index); } }