mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 14:13:43 +01:00
Chat and Review-panel mentions (#34564)
* feat: changing chat to use cm6 editor instead to allow for mentions display and autocomplete * feat: changing review panel comment, edit, and reply to also use cm6 editor to enable mentions * feat: updating tests, and moving from jsdom to cypress tests as jsdom cant handle cm6 * feat: centralizing mentions component and applying to both chat and review-panel * feat: centralizing mentions input, and using changesUsers in chat mentions * fixing dark mode styles of chat * feat: updating mentions dropdown style and adding custom icon generation for mentions cm6 autocomplete tooltip * feat: making mentions look like links, and fixing border radius on tooltip * fix: naming and using correct color util * feat: fixing hover on highlight * update scroll bar dance in review panel when a system had persistent scroll bars, we would run into issues calculating the height of the review panel Co-authored-by: Kristina <7614497+khjrtbrg@users.noreply.github.com> --------- Co-authored-by: Kristina <7614497+khjrtbrg@users.noreply.github.com> GitOrigin-RevId: 96441ad55f9614427506edfecf3d87bae82d1607
This commit is contained in:
committed by
Copybot
co-authored by
Kristina
parent
9ff7e66b99
commit
7e550a9475
@@ -570,6 +570,7 @@
|
||||
"each_user_will_have_access_to": "",
|
||||
"easily_manage_your_project_files_everywhere": "",
|
||||
"edit": "",
|
||||
"edit_comment": "",
|
||||
"edit_comment_error_message": "",
|
||||
"edit_comment_error_title": "",
|
||||
"edit_content_directly": "",
|
||||
@@ -577,6 +578,7 @@
|
||||
"edit_dictionary_empty": "",
|
||||
"edit_dictionary_remove": "",
|
||||
"edit_figure": "",
|
||||
"edit_message": "",
|
||||
"edit_reference": "",
|
||||
"edit_sso_configuration": "",
|
||||
"edit_tag": "",
|
||||
|
||||
@@ -6,7 +6,10 @@ import { debugConsole } from '@/utils/debugging'
|
||||
import { Message, useChatContext } from '@/features/chat/context/chat-context'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
|
||||
import { mentionsFeatureEnabled } from '@/shared/utils/mentions'
|
||||
import { MentionsInput } from '@/shared/components/mentions-input'
|
||||
import { parseMentions } from '@/shared/utils/parse-mentions'
|
||||
import { MentionBadge } from '@/shared/components/mention-badge'
|
||||
|
||||
const MessageContent: FC<{
|
||||
content: Message['content']
|
||||
@@ -47,64 +50,51 @@ const MessageContent: FC<{
|
||||
}
|
||||
}, [content, mounted])
|
||||
|
||||
const completeEdit = useCallback(() => {
|
||||
editMessage(messageId, editedContent)
|
||||
}, [editMessage, editedContent, messageId])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
completeEdit()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelMessageEdit()
|
||||
setEditedContent(content)
|
||||
}
|
||||
const completeEdit = useCallback(
|
||||
(value: string) => {
|
||||
editMessage(messageId, value)
|
||||
},
|
||||
[cancelMessageEdit, completeEdit, content]
|
||||
[editMessage, messageId]
|
||||
)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setEditedContent(e.target.value)
|
||||
},
|
||||
[]
|
||||
)
|
||||
const handleCancel = useCallback(() => {
|
||||
cancelMessageEdit()
|
||||
setEditedContent(content)
|
||||
}, [cancelMessageEdit, content])
|
||||
|
||||
const handleAutoFocus = useCallback(
|
||||
(textarea: HTMLTextAreaElement) => textarea.select(),
|
||||
[]
|
||||
)
|
||||
const mentionsEnabled = mentionsFeatureEnabled()
|
||||
|
||||
return editing ? (
|
||||
<>
|
||||
<AutoExpandingTextArea
|
||||
value={editedContent}
|
||||
style={{ width: '100%' }}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
<MentionsInput
|
||||
className="chat-message-edit-input"
|
||||
label={t('edit_message')}
|
||||
initialValue={content}
|
||||
onChange={setEditedContent}
|
||||
onSubmit={completeEdit}
|
||||
onCancel={handleCancel}
|
||||
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
|
||||
onAutoFocus={handleAutoFocus}
|
||||
selectOnFocus
|
||||
/>
|
||||
<br />
|
||||
<OLButton size="sm" variant="secondary" onClick={handleCancel}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
cancelMessageEdit()
|
||||
setEditedContent(content)
|
||||
}}
|
||||
onClick={() => completeEdit(editedContent)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton size="sm" variant="secondary" onClick={() => completeEdit()}>
|
||||
{t('save')}
|
||||
</OLButton>
|
||||
</>
|
||||
) : (
|
||||
<p ref={root} translate="no">
|
||||
<Linkify>{content}</Linkify>
|
||||
{mentionsEnabled ? (
|
||||
renderMentionedContent(content)
|
||||
) : (
|
||||
<Linkify>{content}</Linkify>
|
||||
)}
|
||||
{edited ? (
|
||||
<>
|
||||
{' '}
|
||||
@@ -115,4 +105,14 @@ const MessageContent: FC<{
|
||||
)
|
||||
}
|
||||
|
||||
function renderMentionedContent(content: string) {
|
||||
return parseMentions(content).map((segment, index) =>
|
||||
segment.type === 'mention' ? (
|
||||
<MentionBadge key={index} userId={segment.userId} />
|
||||
) : (
|
||||
<Linkify key={index}>{segment.value}</Linkify>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageContent
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import {
|
||||
MentionsInput,
|
||||
MentionsInputHandle,
|
||||
} from '@/shared/components/mentions-input'
|
||||
|
||||
type MessageInputProps = {
|
||||
resetUnreadMessages: () => void
|
||||
@@ -7,33 +12,25 @@ type MessageInputProps = {
|
||||
|
||||
function MessageInput({ resetUnreadMessages, sendMessage }: MessageInputProps) {
|
||||
const { t } = useTranslation()
|
||||
const inputRef = useRef<MentionsInputHandle | null>(null)
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent) {
|
||||
const selectingCharacter = event.nativeEvent.isComposing
|
||||
if (event.key === 'Enter' && !selectingCharacter) {
|
||||
event.preventDefault()
|
||||
const target = event.target as HTMLInputElement
|
||||
sendMessage(target.value)
|
||||
// wrap the form reset in setTimeout so input sources have time to finish
|
||||
// https://github.com/overleaf/internal/pull/9206
|
||||
window.setTimeout(() => {
|
||||
target.blur()
|
||||
target.closest('form')?.reset()
|
||||
target.focus()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
const handleSubmit = useCallback(
|
||||
(message: string) => {
|
||||
sendMessage(message)
|
||||
inputRef.current?.clear()
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
return (
|
||||
<form className="new-message">
|
||||
<label htmlFor="chat-input" className="visually-hidden">
|
||||
{`${t('your_message_to_collaborators')}…`}
|
||||
</label>
|
||||
<textarea
|
||||
id="chat-input"
|
||||
<MentionsInput
|
||||
ref={inputRef}
|
||||
className="chat-message-input"
|
||||
label={`${t('your_message_to_collaborators')}…`}
|
||||
placeholder={`${t('your_message_to_collaborators')}…`}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={resetUnreadMessages}
|
||||
onSubmit={handleSubmit}
|
||||
onFocus={resetUnreadMessages}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { ElementType, FC, PropsWithChildren } from 'react'
|
||||
import { ChatProvider } from '@/features/chat/context/chat-context'
|
||||
import { ChangesUsersProvider } from '@/shared/context/changes-users-context'
|
||||
import { ConnectionProvider } from './connection-context'
|
||||
import { DetachCompileProvider } from '@/shared/context/detach-compile-context'
|
||||
import { DetachProvider } from '@/shared/context/detach-context'
|
||||
@@ -47,6 +48,7 @@ export const ReactContextRoot: FC<
|
||||
> = ({ children, providers = {} }) => {
|
||||
const Providers = {
|
||||
ChatProvider,
|
||||
ChangesUsersProvider,
|
||||
ConnectionProvider,
|
||||
DetachCompileProvider,
|
||||
DetachProvider,
|
||||
@@ -130,9 +132,11 @@ export const ReactContextRoot: FC<
|
||||
<Providers.OutlineProvider>
|
||||
<Providers.CommandRegistryProvider>
|
||||
<Providers.EditorSelectionProvider>
|
||||
{
|
||||
childrenWrappedWithDynamicProviders
|
||||
}
|
||||
<Providers.ChangesUsersProvider>
|
||||
{
|
||||
childrenWrappedWithDynamicProviders
|
||||
}
|
||||
</Providers.ChangesUsersProvider>
|
||||
</Providers.EditorSelectionProvider>
|
||||
</Providers.CommandRegistryProvider>
|
||||
</Providers.OutlineProvider>
|
||||
|
||||
+37
-60
@@ -7,13 +7,15 @@ import { EditorSelection } from '@codemirror/state'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useThreadsActionsContext } from '../context/threads-context'
|
||||
import { removeNewCommentRangeEffect } from '@/features/source-editor/extensions/add-comment'
|
||||
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
|
||||
import { ReviewPanelEntry } from './review-panel-entry'
|
||||
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
||||
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { isFormSubmitKeypressEvent } from '@/features/review-panel/utils/form-events'
|
||||
import {
|
||||
MentionsInput,
|
||||
MentionsInputHandle,
|
||||
} from '@/shared/components/mentions-input'
|
||||
|
||||
export const ReviewPanelAddComment = memo<{
|
||||
docId: string
|
||||
@@ -36,55 +38,32 @@ export const ReviewPanelAddComment = memo<{
|
||||
})
|
||||
}, [view, threadId])
|
||||
|
||||
const submitForm = useCallback(async () => {
|
||||
if (content.trim().length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
const text = view.state.sliceDoc(from, to)
|
||||
|
||||
try {
|
||||
await addComment(from, text, content)
|
||||
handleClose()
|
||||
view.dispatch({
|
||||
selection: EditorSelection.cursor(view.state.selection.main.anchor),
|
||||
})
|
||||
} catch (err) {
|
||||
debugConsole.error(err)
|
||||
showGenericMessageModal(
|
||||
t('add_comment_error_title'),
|
||||
t('add_comment_error_message')
|
||||
)
|
||||
}
|
||||
setSubmitting(false)
|
||||
}, [
|
||||
content,
|
||||
view,
|
||||
from,
|
||||
to,
|
||||
addComment,
|
||||
handleClose,
|
||||
showGenericMessageModal,
|
||||
t,
|
||||
])
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (isFormSubmitKeypressEvent(event)) {
|
||||
event.preventDefault()
|
||||
submitForm()
|
||||
const submitForm = useCallback(
|
||||
async (commentContent: string) => {
|
||||
if (commentContent.trim().length === 0) {
|
||||
return
|
||||
}
|
||||
},
|
||||
[submitForm]
|
||||
)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
setSubmitting(true)
|
||||
|
||||
const text = view.state.sliceDoc(from, to)
|
||||
|
||||
try {
|
||||
await addComment(from, text, commentContent)
|
||||
handleClose()
|
||||
view.dispatch({
|
||||
selection: EditorSelection.cursor(view.state.selection.main.anchor),
|
||||
})
|
||||
} catch (err) {
|
||||
debugConsole.error(err)
|
||||
showGenericMessageModal(
|
||||
t('add_comment_error_title'),
|
||||
t('add_comment_error_message')
|
||||
)
|
||||
}
|
||||
setSubmitting(false)
|
||||
},
|
||||
[]
|
||||
[view, from, to, addComment, handleClose, showGenericMessageModal, t]
|
||||
)
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
@@ -98,15 +77,16 @@ export const ReviewPanelAddComment = memo<{
|
||||
const handleSubmit = useCallback<FormEventHandler>(
|
||||
event => {
|
||||
event.preventDefault()
|
||||
submitForm()
|
||||
submitForm(content)
|
||||
},
|
||||
[submitForm]
|
||||
[submitForm, content]
|
||||
)
|
||||
|
||||
// We only ever want to focus the element once
|
||||
const hasBeenFocused = useRef(false)
|
||||
const commentInputRef = useRef<MentionsInputHandle | null>(null)
|
||||
|
||||
// Auto-focus the textarea once the element has been correctly positioned.
|
||||
// Auto-focus the input once the element has been correctly positioned.
|
||||
// We cannot use the autofocus attribute as we need to wait until the parent element
|
||||
// has been positioned (with the "top" attribute) to avoid scrolling to the initial
|
||||
// position of the element
|
||||
@@ -118,9 +98,7 @@ export const ReviewPanelAddComment = memo<{
|
||||
for (const mutation of mutationList) {
|
||||
const target = mutation.target as HTMLElement
|
||||
if (target.style.top) {
|
||||
const textArea = target.getElementsByTagName('textarea')[0]
|
||||
if (textArea) {
|
||||
textArea.focus()
|
||||
if (commentInputRef.current?.focus()) {
|
||||
hasBeenFocused.current = true
|
||||
}
|
||||
}
|
||||
@@ -171,13 +149,12 @@ export const ReviewPanelAddComment = memo<{
|
||||
onSubmit={handleSubmit}
|
||||
ref={handleElement}
|
||||
>
|
||||
<AutoExpandingTextArea
|
||||
name="message"
|
||||
className="review-panel-add-comment-textarea"
|
||||
onChange={handleChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
<MentionsInput
|
||||
ref={commentInputRef}
|
||||
className="review-panel-add-comment-editor"
|
||||
onChange={setContent}
|
||||
onSubmit={submitForm}
|
||||
placeholder={t('add_your_comment_here')}
|
||||
value={content}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div className="review-panel-add-comment-buttons">
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { useChangesUsersContext } from '../context/changes-users-context'
|
||||
import { useChangesUsersContext } from '@/shared/context/changes-users-context'
|
||||
import { Change } from '../../../../../types/change'
|
||||
import ReviewPanelEntryUser from './review-panel-entry-user'
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import classnames from 'classnames'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year'
|
||||
import { useChangesUsersContext } from '../context/changes-users-context'
|
||||
import { useChangesUsersContext } from '@/shared/context/changes-users-context'
|
||||
import { ReviewPanelChangeUser } from './review-panel-change-user'
|
||||
import { ReviewPanelEntry } from './review-panel-entry'
|
||||
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||
|
||||
+31
-41
@@ -1,9 +1,12 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { Change, CommentOperation } from '../../../../../types/change'
|
||||
import { ReviewPanelMessage } from './review-panel-message'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useThreadsContext } from '../context/threads-context'
|
||||
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
|
||||
import {
|
||||
MentionsInput,
|
||||
MentionsInputHandle,
|
||||
} from '@/shared/components/mentions-input'
|
||||
import ReviewPanelResolvedMessage from './review-panel-resolved-message'
|
||||
import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread'
|
||||
import {
|
||||
@@ -11,7 +14,6 @@ import {
|
||||
ThreadId,
|
||||
} from '../../../../../types/review-panel/review-panel'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { isFormSubmitKeypressEvent } from '@/features/review-panel/utils/form-events'
|
||||
|
||||
export const ReviewPanelCommentContent = memo<{
|
||||
comment: Change<CommentOperation>
|
||||
@@ -40,44 +42,32 @@ export const ReviewPanelCommentContent = memo<{
|
||||
const permissions = usePermissionsContext()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [content, setContent] = useState('')
|
||||
const commentInputRef = useRef<MentionsInputHandle | null>(null)
|
||||
|
||||
const hasActiveContent = content.trim().length > 0
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!onReply || submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasActiveContent) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
return onReply(content)
|
||||
.then(() => {
|
||||
setContent('')
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false)
|
||||
})
|
||||
}, [onReply, submitting, content, hasActiveContent])
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (isFormSubmitKeypressEvent(event)) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
const handleSubmit = useCallback(
|
||||
(value: string) => {
|
||||
if (!onReply || submitting) {
|
||||
return
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
if (value.trim().length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
// Clearing the editor fires its change handler, which resets `content`.
|
||||
return onReply(value)
|
||||
.then(() => {
|
||||
commentInputRef.current?.clear()
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false)
|
||||
})
|
||||
},
|
||||
[]
|
||||
[onReply, submitting]
|
||||
)
|
||||
|
||||
const thread = threads?.[comment.op.t]
|
||||
@@ -127,13 +117,13 @@ export const ReviewPanelCommentContent = memo<{
|
||||
)}
|
||||
|
||||
{permissions.comment && !isResolved && (
|
||||
<AutoExpandingTextArea
|
||||
name="content"
|
||||
className="review-panel-comment-input"
|
||||
onChange={handleChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
<MentionsInput
|
||||
ref={commentInputRef}
|
||||
className="review-panel-add-comment-editor review-panel-comment-input"
|
||||
onChange={setContent}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={t('reply')}
|
||||
value={content}
|
||||
disabled={submitting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { memo } from 'react'
|
||||
import { buildName } from '../utils/build-name'
|
||||
import { buildName } from '@/shared/utils/build-name'
|
||||
import { ReviewPanelUser } from '../../../../../types/review-panel/review-panel'
|
||||
import { ChangesUser } from '../context/changes-users-context'
|
||||
import { ChangesUser } from '@/shared/context/changes-users-context'
|
||||
import { getBackgroundColorForUserId } from '@/shared/utils/colors'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
|
||||
@@ -60,13 +60,22 @@ export const ReviewPanelEntry: FC<
|
||||
(event: React.FocusEvent | React.MouseEvent) => {
|
||||
setFocused(true)
|
||||
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
// The add-comment input is a CodeMirror editor (contenteditable div)
|
||||
// rather than a textarea, so also treat focus landing inside it as a
|
||||
// text-input focus.
|
||||
const target = event.target
|
||||
const isTextInput =
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
(target instanceof HTMLElement &&
|
||||
target.closest('.review-panel-add-comment-editor') !== null)
|
||||
|
||||
if (isTextInput) {
|
||||
const entryBottom =
|
||||
(entryRef.current?.offsetTop || 0) +
|
||||
(entryRef.current?.offsetHeight || 0)
|
||||
|
||||
if (entryBottom > OFFSET_FOR_ENTRIES_ABOVE) {
|
||||
// if the entry textarea is visible, no need to select the entry
|
||||
// if the entry input is visible, no need to select the entry
|
||||
// so that it doesn't scroll out of view as user types
|
||||
setTextareaFocused(true)
|
||||
return
|
||||
|
||||
+2
-2
@@ -3,12 +3,12 @@ import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classNames from 'classnames'
|
||||
import { PreventSelectingEntry } from './review-panel-prevent-selecting'
|
||||
import { MentionBadge } from './mention-badge'
|
||||
import { MentionBadge } from '@/shared/components/mention-badge'
|
||||
import {
|
||||
MentionSegment,
|
||||
parseMentions,
|
||||
sliceMentionSegments,
|
||||
} from '../utils/parse-mentions'
|
||||
} from '@/shared/utils/parse-mentions'
|
||||
|
||||
export const ExpandableContent = memo<{
|
||||
className?: string
|
||||
|
||||
+30
-28
@@ -1,4 +1,4 @@
|
||||
import { FC, useCallback, useState } from 'react'
|
||||
import { FC, useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
CommentId,
|
||||
ReviewPanelCommentThreadMessage,
|
||||
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
|
||||
import { MentionsInput } from '@/shared/components/mentions-input'
|
||||
import ReviewPanelCommentOptions from './review-panel-comment-options'
|
||||
import { ExpandableContent } from './review-panel-expandable-content'
|
||||
import ReviewPanelDeleteCommentModal from './review-panel-delete-comment-modal'
|
||||
@@ -15,7 +15,7 @@ import { useUserContext } from '@/shared/context/user-context'
|
||||
import ReviewPanelEntryUser from './review-panel-entry-user'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { PreventSelectingEntry } from './review-panel-prevent-selecting'
|
||||
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
|
||||
import { mentionsFeatureEnabled } from '@/shared/utils/mentions'
|
||||
|
||||
export const ReviewPanelMessage: FC<{
|
||||
message: ReviewPanelCommentThreadMessage
|
||||
@@ -39,12 +39,12 @@ export const ReviewPanelMessage: FC<{
|
||||
const { t } = useTranslation()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [content, setContent] = useState(message.content)
|
||||
// Guards against a double submit when Enter submits and the subsequent
|
||||
// unmount of the editor also fires its blur handler.
|
||||
const hasSubmittedRef = useRef(false)
|
||||
const user = useUserContext()
|
||||
const permissions = usePermissionsContext()
|
||||
const mentionsEnabled =
|
||||
isSplitTestEnabled('email-notifications') &&
|
||||
isSplitTestEnabled('comment-mentions')
|
||||
const mentionsEnabled = mentionsFeatureEnabled()
|
||||
|
||||
const isCommentAuthor = Boolean(message.user && user.id === message.user.id)
|
||||
const canEdit = isCommentAuthor && permissions.comment
|
||||
@@ -53,14 +53,27 @@ export const ReviewPanelMessage: FC<{
|
||||
(permissions.resolveOwnComments && isCommentAuthor)
|
||||
const canDelete = canResolve
|
||||
|
||||
const handleEditOption = useCallback(() => setEditing(true), [])
|
||||
const handleEditOption = useCallback(() => {
|
||||
hasSubmittedRef.current = false
|
||||
setEditing(true)
|
||||
}, [])
|
||||
const showDeleteModal = useCallback(() => setDeleting(true), [])
|
||||
const hideDeleteModal = useCallback(() => setDeleting(false), [])
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
onEdit?.(message.id, content)
|
||||
setEditing(false)
|
||||
}, [content, message.id, onEdit])
|
||||
const handleSubmit = useCallback(
|
||||
(value: string) => {
|
||||
if (hasSubmittedRef.current) {
|
||||
return
|
||||
}
|
||||
hasSubmittedRef.current = true
|
||||
// An emptied comment cancels the edit rather than saving a blank message.
|
||||
if (value.trim().length > 0) {
|
||||
onEdit?.(message.id, value)
|
||||
}
|
||||
setEditing(false)
|
||||
},
|
||||
[message.id, onEdit]
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onDelete?.()
|
||||
@@ -119,23 +132,12 @@ export const ReviewPanelMessage: FC<{
|
||||
</div>
|
||||
</div>
|
||||
{editing ? (
|
||||
<AutoExpandingTextArea
|
||||
className="review-panel-comment-input review-panel-comment-edit"
|
||||
<MentionsInput
|
||||
className="review-panel-add-comment-editor review-panel-comment-input review-panel-comment-edit"
|
||||
label={t('edit_comment')}
|
||||
initialValue={message.content}
|
||||
onSubmit={handleSubmit}
|
||||
onBlur={handleSubmit}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
onKeyPress={e => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
content
|
||||
) {
|
||||
e.preventDefault()
|
||||
;(e.target as HTMLTextAreaElement).blur()
|
||||
}
|
||||
}}
|
||||
value={content}
|
||||
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FC } from 'react'
|
||||
import { RangesProvider } from './ranges-context'
|
||||
import { ChangesUsersProvider } from './changes-users-context'
|
||||
import { TrackChangesStateProvider } from './track-changes-state-context'
|
||||
import { ThreadsProvider } from './threads-context'
|
||||
import { ReviewPanelViewProvider } from './review-panel-view-context'
|
||||
@@ -16,13 +15,11 @@ export const ReviewPanelProviders: FC<React.PropsWithChildren> = ({
|
||||
|
||||
return (
|
||||
<ReviewPanelViewProvider>
|
||||
<ChangesUsersProvider>
|
||||
<TrackChangesStateProvider>
|
||||
<ThreadsProvider>
|
||||
<RangesProvider>{children}</RangesProvider>
|
||||
</ThreadsProvider>
|
||||
</TrackChangesStateProvider>
|
||||
</ChangesUsersProvider>
|
||||
<TrackChangesStateProvider>
|
||||
<ThreadsProvider>
|
||||
<RangesProvider>{children}</RangesProvider>
|
||||
</ThreadsProvider>
|
||||
</TrackChangesStateProvider>
|
||||
</ReviewPanelViewProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export const isFormSubmitKeypressEvent = (
|
||||
event: React.KeyboardEvent<HTMLTextAreaElement>
|
||||
) => {
|
||||
return (
|
||||
event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
MatchDecorator,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
WidgetType,
|
||||
} from '@codemirror/view'
|
||||
import { Completion, CompletionContext } from '@codemirror/autocomplete'
|
||||
import { projectMembersInfo } from './project-members-info'
|
||||
import { MENTION_REGEX } from '@/shared/utils/parse-mentions'
|
||||
import { getBackgroundColorForUserId } from '@/shared/utils/colors'
|
||||
|
||||
class MentionsWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly userId: string,
|
||||
readonly name: string
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'ol-cm-mention'
|
||||
span.textContent = '@' + this.name
|
||||
return span
|
||||
}
|
||||
|
||||
eq(other: MentionsWidget) {
|
||||
return other.userId === this.userId && other.name === this.name
|
||||
}
|
||||
}
|
||||
|
||||
const matchDecorator = new MatchDecorator({
|
||||
regexp: MENTION_REGEX,
|
||||
decoration: (match, view) => {
|
||||
const id = match[1]
|
||||
const name =
|
||||
view.state.field(projectMembersInfo).get(id)?.name ??
|
||||
view.state.phrase('unknown')
|
||||
return Decoration.replace({ widget: new MentionsWidget(id, name) })
|
||||
},
|
||||
})
|
||||
|
||||
// Autocompletion source for `@mention`s. Offers the project members and, on
|
||||
// selection, inserts `[id]` right after the `@` so the stored text is `@[id]`.
|
||||
export function mentionCompletions(context: CompletionContext) {
|
||||
const before = context.matchBefore(/@\w*/)
|
||||
if (!context.explicit && !before) {
|
||||
return null
|
||||
}
|
||||
const members = context.state.field(projectMembersInfo)
|
||||
const options = Array.from(members.entries()).map(([id, info]) => ({
|
||||
label: info.name,
|
||||
detail: info.email,
|
||||
apply: `[${id}]`,
|
||||
// Carried through to renderMentionAvatar, which colours the avatar circle
|
||||
// by user id.
|
||||
mentionId: id,
|
||||
}))
|
||||
return {
|
||||
from: before ? before.from + 1 : context.pos,
|
||||
options,
|
||||
validFor: /^\w*$/,
|
||||
}
|
||||
}
|
||||
|
||||
// Marker class added to the mentions autocomplete tooltip (via the
|
||||
// `tooltipClass` option in mentions-input.tsx). The tooltip is parented to
|
||||
// `document.body`, so the source editor's global autocomplete base theme
|
||||
// (auto-complete.ts) also lands on it. Scoping every rule under this class
|
||||
// both isolates the mentions styling from the LaTeX autocomplete and raises
|
||||
// specificity enough to win over those global `li[role="option"]` /
|
||||
// `.cm-completionDetail` rules. The `li[role="option"]` selector is used for
|
||||
// the same reason.
|
||||
export const MENTIONS_TOOLTIP_CLASS = 'ol-cm-mentions-autocomplete'
|
||||
|
||||
export const mentionAutocompleteTheme = EditorView.baseTheme({
|
||||
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete': {
|
||||
marginLeft: '0',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
borderRadius: 'var(--border-radius-large)',
|
||||
},
|
||||
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete > ul': {
|
||||
maxWidth: 'min(360px, 90vw)',
|
||||
whiteSpace: 'normal',
|
||||
padding: 'var(--spacing-02)',
|
||||
border: '1px solid var(--border-divider)',
|
||||
borderRadius: 'var(--border-radius-large)',
|
||||
background: 'var(--bg-light-primary)',
|
||||
// Matches the `shadow-md` elevation mixin (tooltips/dropdowns).
|
||||
boxShadow: '0 4px 12px 0 rgb(30 37 48 / 12%), 0 2px 4px rgb(30 37 48 / 8%)',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
},
|
||||
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete > ul > li[role="option"]':
|
||||
{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr',
|
||||
gridTemplateRows: 'auto auto',
|
||||
alignItems: 'center',
|
||||
columnGap: 'var(--spacing-04)',
|
||||
padding: 'var(--spacing-03) var(--spacing-04)',
|
||||
overflowWrap: 'anywhere',
|
||||
borderRadius: 'var(--border-radius-medium)',
|
||||
lineHeight: '1.3',
|
||||
},
|
||||
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete > ul > li[role="option"][aria-selected]':
|
||||
{
|
||||
background: 'var(--bg-light-secondary)',
|
||||
},
|
||||
|
||||
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete .ol-cm-mention-avatar':
|
||||
{
|
||||
gridColumn: '1',
|
||||
gridRow: '1 / span 2',
|
||||
alignSelf: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: 'var(--border-radius-full)',
|
||||
color: 'var(--white)',
|
||||
fontSize: 'var(--font-size-02)',
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete .cm-completionLabel':
|
||||
{
|
||||
gridColumn: '2',
|
||||
gridRow: '1',
|
||||
fontSize: 'var(--font-size-03)',
|
||||
color: 'var(--content-primary)',
|
||||
},
|
||||
// The portion of the label matching what the user has typed.
|
||||
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete .cm-completionMatchedText':
|
||||
{
|
||||
fontWeight: '700',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
// details
|
||||
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete .cm-completionDetail':
|
||||
{
|
||||
gridColumn: '2',
|
||||
gridRow: '2',
|
||||
margin: '0',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 'var(--font-size-02)',
|
||||
fontStyle: 'normal',
|
||||
color: 'var(--content-secondary)',
|
||||
},
|
||||
})
|
||||
|
||||
// Renders a project member's avatar circle for the autocomplete option. The
|
||||
// background uses the same per-user hue as the rest of the editor's
|
||||
// collaborator colours (via getHueForUserId), so a user looks consistent
|
||||
// across mentions, cursors, and the review panel. The colour is set inline
|
||||
// per-user rather than generated as a theme, keeping the styling static (see
|
||||
// `no-generated-editor-themes`). Wired in via the `addToOptions` option in
|
||||
// mentions-input.tsx;
|
||||
export function renderMentionAvatar(completion: Completion) {
|
||||
const id = (completion as Completion & { mentionId?: string }).mentionId
|
||||
const avatar = document.createElement('span')
|
||||
avatar.className = 'ol-cm-mention-avatar'
|
||||
avatar.style.backgroundColor = getBackgroundColorForUserId(id)
|
||||
const label = completion.displayLabel ?? completion.label
|
||||
avatar.textContent = label.trim().charAt(0)
|
||||
return avatar
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor extension that renders stored `@[<id>]` mention tokens as `@name`
|
||||
* chips, resolving ids to names via the projectMembersInfo state field.
|
||||
*/
|
||||
export const mentions = () => {
|
||||
return [
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = matchDecorator.createDeco(view)
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
const namesChanged =
|
||||
update.startState.field(projectMembersInfo) !==
|
||||
update.state.field(projectMembersInfo)
|
||||
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = matchDecorator.updateDeco(
|
||||
update,
|
||||
this.decorations
|
||||
)
|
||||
}
|
||||
if (namesChanged) {
|
||||
// full rebuild re-runs the `decoration` callback, re-resolving names
|
||||
this.decorations = matchDecorator.createDeco(update.view)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations(value) {
|
||||
return value.decorations
|
||||
},
|
||||
provide: value => {
|
||||
return EditorView.atomicRanges.of(view => {
|
||||
return view.plugin(value)?.decorations || Decoration.none
|
||||
})
|
||||
},
|
||||
}
|
||||
),
|
||||
emptyLineFillerTheme,
|
||||
mentionTheme,
|
||||
]
|
||||
}
|
||||
|
||||
// single atomicRange.
|
||||
const mentionTheme = EditorView.baseTheme({
|
||||
'.ol-cm-mention': {
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
fontWeight: '500',
|
||||
color: 'var(--link-ui-themed)',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'default',
|
||||
},
|
||||
'.ol-cm-mention::selection': {
|
||||
color: 'var(--white)',
|
||||
},
|
||||
})
|
||||
|
||||
const emptyLineFillerTheme = EditorView.baseTheme({
|
||||
'.ol-cm-filler': {
|
||||
padding: '0 2px',
|
||||
},
|
||||
})
|
||||
|
||||
export default mentions
|
||||
@@ -0,0 +1,28 @@
|
||||
import { StateEffect, StateField } from '@codemirror/state'
|
||||
|
||||
export type MemberInfo = {
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export const projectMembersInfo = StateField.define<Map<string, MemberInfo>>({
|
||||
create: () => {
|
||||
return new Map()
|
||||
},
|
||||
update(value, transaction) {
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(updateCollaboratorsEffect)) {
|
||||
value = effect.value
|
||||
}
|
||||
}
|
||||
return value
|
||||
},
|
||||
})
|
||||
|
||||
const updateCollaboratorsEffect = StateEffect.define<Map<string, MemberInfo>>()
|
||||
|
||||
export const setProjectMembersInfo = (idToNameMap: Map<string, MemberInfo>) => {
|
||||
return {
|
||||
effects: updateCollaboratorsEffect.of(idToNameMap),
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
import { useId, type FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { UserId } from '../../../../../types/user'
|
||||
import { useChangesUsersContext } from '../context/changes-users-context'
|
||||
import { buildName } from '../utils/build-name'
|
||||
import { UserId } from '@ol-types/user'
|
||||
import { useChangesUsersContext } from '@/shared/context/changes-users-context'
|
||||
import { buildName } from '@/shared/utils/build-name'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
|
||||
export const MentionBadge: FC<{ userId: UserId }> = ({ userId }) => {
|
||||
@@ -12,7 +12,7 @@ export const MentionBadge: FC<{ userId: UserId }> = ({ userId }) => {
|
||||
const user = changesUsers?.get(userId)
|
||||
|
||||
if (!user) {
|
||||
return <span className="review-panel-mention">@{t('unknown')}</span>
|
||||
return <span className="mention-badge">@{t('unknown')}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -22,7 +22,7 @@ export const MentionBadge: FC<{ userId: UserId }> = ({ userId }) => {
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
tooltipProps={{ className: 'review-panel-tooltip' }}
|
||||
>
|
||||
<span className="review-panel-mention">@{buildName(user)}</span>
|
||||
<span className="mention-badge">@{buildName(user)}</span>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||
import {
|
||||
EditorView,
|
||||
keymap,
|
||||
tooltips,
|
||||
drawSelection,
|
||||
placeholder as placeholderExt,
|
||||
} from '@codemirror/view'
|
||||
import { Compartment, EditorSelection } from '@codemirror/state'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { autocompletion, completionStatus } from '@codemirror/autocomplete'
|
||||
import classNames from 'classnames'
|
||||
import mentions, {
|
||||
mentionCompletions,
|
||||
mentionAutocompleteTheme,
|
||||
renderMentionAvatar,
|
||||
MENTIONS_TOOLTIP_CLASS,
|
||||
} from '@/features/source-editor/extensions/mentions'
|
||||
import {
|
||||
projectMembersInfo,
|
||||
setProjectMembersInfo,
|
||||
} from '@/features/source-editor/extensions/project-members-info'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { membersToIdMap, mentionsFeatureEnabled } from '@/shared/utils/mentions'
|
||||
|
||||
export type MentionsInputHandle = {
|
||||
// Returns true if the editor was focused, false if the view isn't ready yet.
|
||||
focus: () => boolean
|
||||
// Empties the editor. Used by inputs that stay mounted between submissions
|
||||
// (the chat composer and the review-panel reply box).
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
type MentionsInputProps = {
|
||||
onSubmit: (value: string) => void
|
||||
onChange?: (value: string) => void
|
||||
onBlur?: (value: string) => void
|
||||
// Called on Escape (when the autocomplete popup is not open). Used by the
|
||||
// edit flows to cancel without saving.
|
||||
onCancel?: () => void
|
||||
onFocus?: () => void
|
||||
placeholder?: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
initialValue?: string
|
||||
autoFocus?: boolean
|
||||
selectOnFocus?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Shared editor styling for the mentions input.
|
||||
const mentionsInputTheme = EditorView.theme({
|
||||
// double the specificity to override the base font family used by theme.ts regardless of extension load order
|
||||
'.cm-content.cm-content': {
|
||||
fontFamily: 'var(--bs-body-font-family)',
|
||||
},
|
||||
// when range in editor is focused and selected
|
||||
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
||||
backgroundColor: 'var(--bg-info-01)',
|
||||
},
|
||||
'&.cm-focused .cm-content ::selection': {
|
||||
color: 'var(--white)',
|
||||
},
|
||||
// when range in editor is exists but focus is outside editor, so ::selection is not applied
|
||||
'.cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground': {
|
||||
backgroundColor: 'color-mix(in srgb, var(--bg-info-01) 24%, transparent)',
|
||||
},
|
||||
})
|
||||
|
||||
// A small CodeMirror 6 input that supports `@mention` autocompletion. Shared by
|
||||
// the chat composer/edit field and the review panel's add-comment, reply, and
|
||||
// edit fields so all of them get the same mention behaviour.
|
||||
export const MentionsInput = forwardRef<
|
||||
MentionsInputHandle,
|
||||
MentionsInputProps
|
||||
>(function MentionsInput(
|
||||
{
|
||||
onChange,
|
||||
onSubmit,
|
||||
onBlur,
|
||||
onCancel,
|
||||
onFocus,
|
||||
placeholder = '',
|
||||
label,
|
||||
disabled = false,
|
||||
initialValue = '',
|
||||
autoFocus = false,
|
||||
selectOnFocus = false,
|
||||
className,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const editableConf = useRef(new Compartment())
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const { members, owner } = project || { members: [] }
|
||||
|
||||
// The keymap, update listener and event handlers are wired once on mount,
|
||||
// so route them through refs to always call the latest props rather than
|
||||
// stale closures.
|
||||
const onChangeRef = useRef(onChange)
|
||||
const onSubmitRef = useRef(onSubmit)
|
||||
const onBlurRef = useRef(onBlur)
|
||||
const onCancelRef = useRef(onCancel)
|
||||
const onFocusRef = useRef(onFocus)
|
||||
const disabledRef = useRef(disabled)
|
||||
onChangeRef.current = onChange
|
||||
onSubmitRef.current = onSubmit
|
||||
onBlurRef.current = onBlur
|
||||
onCancelRef.current = onCancel
|
||||
onFocusRef.current = onFocus
|
||||
disabledRef.current = disabled
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => {
|
||||
if (viewRef.current) {
|
||||
viewRef.current.focus()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
clear: () => {
|
||||
const view = viewRef.current
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: '' },
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const ariaLabel = label ?? placeholder
|
||||
const mentionsEnabled = mentionsFeatureEnabled()
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const view = new EditorView({
|
||||
doc: initialValue,
|
||||
parent: containerRef.current,
|
||||
extensions: [
|
||||
projectMembersInfo,
|
||||
EditorView.lineWrapping,
|
||||
drawSelection(),
|
||||
mentionsInputTheme,
|
||||
placeholderExt(placeholder),
|
||||
...(mentionsEnabled
|
||||
? [
|
||||
tooltips({ parent: document.body }),
|
||||
mentions(),
|
||||
autocompletion({
|
||||
override: [mentionCompletions],
|
||||
tooltipClass: () => MENTIONS_TOOLTIP_CLASS,
|
||||
// Replace the default icon with our coloured avatar circle.
|
||||
icons: false,
|
||||
addToOptions: [{ render: renderMentionAvatar, position: 20 }],
|
||||
}),
|
||||
mentionAutocompleteTheme,
|
||||
]
|
||||
: []),
|
||||
editableConf.current.of(EditorView.editable.of(!disabled)),
|
||||
...(ariaLabel
|
||||
? [EditorView.contentAttributes.of({ 'aria-label': ariaLabel })]
|
||||
: []),
|
||||
EditorView.updateListener.of(update => {
|
||||
if (update.docChanged) {
|
||||
onChangeRef.current?.(update.state.doc.toString())
|
||||
}
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
onFocusRef.current?.()
|
||||
return false
|
||||
},
|
||||
blur: (_event, view) => {
|
||||
if (completionStatus(view.state) === 'active') {
|
||||
return false
|
||||
}
|
||||
onBlurRef.current?.(view.state.doc.toString())
|
||||
return false
|
||||
},
|
||||
}),
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Enter',
|
||||
run: view => {
|
||||
if (disabledRef.current) {
|
||||
return true
|
||||
}
|
||||
onSubmitRef.current(view.state.doc.toString())
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
run: view => {
|
||||
// Let the autocomplete popup handle Escape when it's open.
|
||||
if (completionStatus(view.state) === 'active') {
|
||||
return false
|
||||
}
|
||||
if (onCancelRef.current) {
|
||||
onCancelRef.current()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
history(),
|
||||
],
|
||||
})
|
||||
viewRef.current = view
|
||||
if (autoFocus) {
|
||||
view.focus()
|
||||
if (selectOnFocus) {
|
||||
view.dispatch({
|
||||
selection: EditorSelection.range(0, view.state.doc.length),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Set the mentionable members on mount and keep them in sync. This effect
|
||||
// runs right after the view is created above, so it also seeds the names.
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch(
|
||||
setProjectMembersInfo(membersToIdMap(members, owner))
|
||||
)
|
||||
}, [members, owner])
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
effects: editableConf.current.reconfigure(
|
||||
EditorView.editable.of(!disabled)
|
||||
),
|
||||
})
|
||||
}, [disabled])
|
||||
|
||||
return <div ref={containerRef} className={classNames(className)} />
|
||||
})
|
||||
|
||||
export default MentionsInput
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
} from 'react'
|
||||
import { getJSON } from '@/infrastructure/fetch-json'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { UserId } from '../../../../../types/user'
|
||||
import { UserId } from '@ol-types/user'
|
||||
import { useEditorContext } from '@/shared/context/editor-context'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { captureException } from '@/infrastructure/error-reporter'
|
||||
@@ -13,6 +13,15 @@ export type ProjectMember = {
|
||||
pendingReviewer?: boolean
|
||||
}
|
||||
|
||||
export type ProjectOwner = {
|
||||
_id: UserId
|
||||
email: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
privileges: string
|
||||
signUpDate: string
|
||||
}
|
||||
|
||||
export interface ProjectMetadata extends ProjectSettings {
|
||||
_id: string
|
||||
mainBibliographyDocId?: string
|
||||
@@ -32,14 +41,7 @@ export interface ProjectMetadata extends ProjectSettings {
|
||||
github?: boolean
|
||||
}
|
||||
publicAccessLevel?: PublicAccessLevel
|
||||
owner: {
|
||||
_id: UserId
|
||||
email: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
privileges: string
|
||||
signUpDate: string
|
||||
}
|
||||
owner: ProjectOwner
|
||||
rootFolder?: Folder[]
|
||||
trackChangesState?: false | TrackChangesStateData
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
ProjectMember,
|
||||
ProjectOwner,
|
||||
} from '@/shared/context/types/project-metadata'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
|
||||
import { buildName } from '@/shared/utils/build-name'
|
||||
import { MemberInfo } from '@/features/source-editor/extensions/project-members-info'
|
||||
|
||||
export const membersToIdMap = (
|
||||
members: ProjectMember[],
|
||||
owner: ProjectOwner | undefined
|
||||
) => {
|
||||
const collaboratorIdToName = new Map<string, MemberInfo>()
|
||||
|
||||
if (!owner) {
|
||||
debugConsole.error('no owner found for project')
|
||||
return collaboratorIdToName
|
||||
}
|
||||
|
||||
collaboratorIdToName.set(owner._id, {
|
||||
name: buildName(owner),
|
||||
email: owner.email,
|
||||
})
|
||||
|
||||
for (const member of members) {
|
||||
collaboratorIdToName.set(member._id, {
|
||||
name: buildName(member),
|
||||
email: member.email,
|
||||
})
|
||||
}
|
||||
return collaboratorIdToName
|
||||
}
|
||||
|
||||
// Mention display (parsing @[id] tokens into names) is gated behind both split
|
||||
// tests, so chat and the review panel render mentions consistently.
|
||||
export const mentionsFeatureEnabled = () =>
|
||||
isSplitTestEnabled('email-notifications') &&
|
||||
isSplitTestEnabled('comment-mentions')
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { UserId } from '../../../../../types/user'
|
||||
import { UserId } from '@ol-types/user'
|
||||
|
||||
export type MentionSegment =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'mention'; userId: UserId }
|
||||
|
||||
const MENTION_REGEX = /@\[([a-f0-9]{24})\]/g
|
||||
export const MENTION_REGEX = /@\[([a-f0-9]{24})\]/g
|
||||
|
||||
export function parseMentions(content: string): MentionSegment[] {
|
||||
const segments: MentionSegment[] = []
|
||||
@@ -90,6 +90,35 @@
|
||||
padding: calc(var(--line-height-03) / 4);
|
||||
background-color: var(--chat-new-message-textarea-bg);
|
||||
}
|
||||
|
||||
.chat-message-input {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.cm-editor {
|
||||
height: 100%;
|
||||
border-radius: var(--border-radius-base);
|
||||
border: 1px solid var(--border-divider-themed);
|
||||
background-color: var(--chat-new-message-textarea-bg);
|
||||
|
||||
&.cm-focused {
|
||||
// suppress CM6's default focus outline; the border conveys focus
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
color: var(--chat-new-message-textarea-color);
|
||||
font-size: var(--font-size-02);
|
||||
line-height: var(--line-height-02);
|
||||
padding: calc(var(--line-height-03) / 4);
|
||||
}
|
||||
|
||||
.cm-cursor,
|
||||
.cm-cursor-primary {
|
||||
border-left-color: var(--chat-new-message-textarea-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +250,36 @@
|
||||
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.mention-badge {
|
||||
color: var(--content-info);
|
||||
}
|
||||
|
||||
.chat-message-edit-input {
|
||||
width: 100%;
|
||||
|
||||
.cm-editor {
|
||||
border-radius: var(--border-radius-base);
|
||||
border: 1px solid var(--border-divider-themed);
|
||||
background-color: var(--chat-new-message-textarea-bg);
|
||||
|
||||
&.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
color: var(--chat-new-message-textarea-color);
|
||||
font-size: var(--font-size-02);
|
||||
line-height: var(--line-height-02);
|
||||
padding: calc(var(--line-height-03) / 4);
|
||||
}
|
||||
|
||||
.cm-cursor,
|
||||
.cm-cursor-primary {
|
||||
border-left-color: var(--chat-new-message-textarea-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-container.message-from-self {
|
||||
|
||||
@@ -373,29 +373,10 @@ del.review-panel-content-highlight {
|
||||
}
|
||||
}
|
||||
|
||||
.review-panel-mention {
|
||||
.mention-badge {
|
||||
color: var(--content-info-themed);
|
||||
}
|
||||
|
||||
.review-panel-comment-input {
|
||||
width: 100%;
|
||||
font-size: var(--rp-base-font-size);
|
||||
padding: 2px var(--spacing-03);
|
||||
border-radius: var(--border-radius-base);
|
||||
border: solid 1px var(--border-primary-themed);
|
||||
resize: vertical;
|
||||
color: var(--review-panel-color-secondary);
|
||||
background-color: var(--review-panel-entry-bg-color);
|
||||
height: 25px;
|
||||
min-height: 25px;
|
||||
overflow-x: hidden;
|
||||
max-height: 400px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--content-placeholder-themed);
|
||||
}
|
||||
}
|
||||
|
||||
.review-panel-comment-edit {
|
||||
margin-top: var(--spacing-03);
|
||||
}
|
||||
@@ -507,16 +488,61 @@ del.review-panel-content-highlight {
|
||||
}
|
||||
}
|
||||
|
||||
.review-panel-add-comment-textarea {
|
||||
padding: var(--spacing-01) var(--spacing-03);
|
||||
resize: vertical;
|
||||
min-height: 44px;
|
||||
background: var(--bg-primary-themed);
|
||||
color: var(--content-primary-themed);
|
||||
.review-panel-add-comment-editor {
|
||||
background: var(--review-panel-entry-bg-color);
|
||||
color: var(--review-panel-color);
|
||||
border: 1px solid var(--border-primary-themed);
|
||||
border-radius: var(--border-radius-base);
|
||||
overflow: hidden;
|
||||
cursor: text;
|
||||
|
||||
&::placeholder {
|
||||
&.review-panel-comment-input {
|
||||
font-size: var(--rp-base-font-size);
|
||||
color: var(--review-panel-color-secondary);
|
||||
|
||||
.cm-content {
|
||||
// Default to two lines tall so long @mentions wrap into view
|
||||
min-height: calc((2 * var(--line-height-02)) + (2 * var(--spacing-01)));
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
max-height: 200px;
|
||||
|
||||
&.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
font-family: inherit;
|
||||
line-height: var(--line-height-02);
|
||||
|
||||
// Hide scrollbars so they don't consume layout width on systems that use
|
||||
// non-overlay scrollbars (e.g. macOS "always show scrollbars"). A visible
|
||||
// scrollbar causes a width change mid-init that fires CM's ResizeObserver,
|
||||
// which triggers extra remeasure/reposition cycles — visible as a "dance".
|
||||
// Scrolling still works via trackpad/keyboard.
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
padding: var(--spacing-01) var(--spacing-03);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.cm-cursor,
|
||||
.cm-cursor-primary {
|
||||
border-left-color: var(--review-panel-color);
|
||||
}
|
||||
|
||||
.cm-placeholder {
|
||||
color: var(--content-placeholder-themed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,6 +740,7 @@
|
||||
"each_user_will_have_access_to": "Each user will have access to",
|
||||
"easily_manage_your_project_files_everywhere": "Easily manage your project files, everywhere",
|
||||
"edit": "Edit",
|
||||
"edit_comment": "Edit comment",
|
||||
"edit_comment_error_message": "There was an error editing the comment. Please try again in a few moments.",
|
||||
"edit_comment_error_title": "Edit Comment Error",
|
||||
"edit_content_directly": "Edit content directly",
|
||||
@@ -747,6 +748,7 @@
|
||||
"edit_dictionary_empty": "Your custom dictionary is empty.",
|
||||
"edit_dictionary_remove": "Remove from dictionary",
|
||||
"edit_figure": "Edit figure",
|
||||
"edit_message": "Edit message",
|
||||
"edit_reference": "Edit reference",
|
||||
"edit_sso_configuration": "Edit SSO Configuration",
|
||||
"edit_tag": "Edit tag",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import ChatPane from '@/features/chat/components/chat-pane'
|
||||
import {
|
||||
EditorProviders,
|
||||
makeProjectProvider,
|
||||
USER_ID,
|
||||
USER_EMAIL,
|
||||
} from '../../../helpers/editor-providers'
|
||||
import { mockProject } from '../../source-editor/helpers/mock-project'
|
||||
|
||||
describe('<MessageContent /> editing', function () {
|
||||
const message = {
|
||||
id: 'msg_1',
|
||||
content: 'hello world',
|
||||
user: {
|
||||
id: USER_ID,
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: USER_EMAIL,
|
||||
},
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
||||
window.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'chat-edit-delete': 'enabled',
|
||||
})
|
||||
|
||||
cy.intercept('GET', '/project/*/messages*', [message]).as('getMessages')
|
||||
cy.intercept('POST', '/project/*/messages/msg_1/edit', {}).as('editMessage')
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
providers={{ ProjectProvider: makeProjectProvider(mockProject()) }}
|
||||
>
|
||||
<ChatPane />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.wait('@getMessages')
|
||||
cy.findByText('hello world')
|
||||
})
|
||||
|
||||
function openEditor() {
|
||||
cy.get('.message-dropdown-menu-btn').click({ force: true })
|
||||
cy.findByText('Edit').click({ force: true })
|
||||
return cy.findByRole('textbox', { name: 'Edit message' })
|
||||
}
|
||||
|
||||
it('saves an edited message on Enter', function () {
|
||||
openEditor().type('{selectAll}edited message{enter}', { force: true })
|
||||
|
||||
cy.wait('@editMessage')
|
||||
.its('request.body.content')
|
||||
.should('equal', 'edited message')
|
||||
})
|
||||
|
||||
it('saves an edited message with the Save button', function () {
|
||||
openEditor().type('{selectAll}edited message', { force: true })
|
||||
cy.findByRole('button', { name: 'Save' }).click({ force: true })
|
||||
|
||||
cy.wait('@editMessage')
|
||||
.its('request.body.content')
|
||||
.should('equal', 'edited message')
|
||||
})
|
||||
|
||||
it('cancels editing on Escape without saving', function () {
|
||||
openEditor().type('{selectAll}discarded{esc}', { force: true })
|
||||
|
||||
// Editor closes and the original message is shown again.
|
||||
cy.findByRole('textbox', { name: 'Edit message' }).should('not.exist')
|
||||
cy.findByText('hello world').should('exist')
|
||||
cy.get('@editMessage.all').should('have.length', 0)
|
||||
})
|
||||
|
||||
it('cancels editing with the Cancel button', function () {
|
||||
openEditor().type('{selectAll}discarded', { force: true })
|
||||
cy.findByRole('button', { name: 'Cancel' }).click({ force: true })
|
||||
|
||||
cy.findByRole('textbox', { name: 'Edit message' }).should('not.exist')
|
||||
cy.findByText('hello world').should('exist')
|
||||
cy.get('@editMessage.all').should('have.length', 0)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,8 @@ import { UserProvider } from '@/shared/context/user-context'
|
||||
import { ModalsContextProvider } from '@/features/ide-react/context/modals-context'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
|
||||
describe('<MessageGroup />', function () {
|
||||
function ChatProviders({
|
||||
children,
|
||||
@@ -27,15 +29,17 @@ describe('<MessageGroup />', function () {
|
||||
}
|
||||
|
||||
return (
|
||||
<UserProvider>
|
||||
<ModalsContextProvider>
|
||||
<SplitTestProvider>
|
||||
<ChatContext.Provider value={mockContextValue as any}>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
</SplitTestProvider>
|
||||
</ModalsContextProvider>
|
||||
</UserProvider>
|
||||
<EditorProviders>
|
||||
<UserProvider>
|
||||
<ModalsContextProvider>
|
||||
<SplitTestProvider>
|
||||
<ChatContext.Provider value={mockContextValue as any}>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
</SplitTestProvider>
|
||||
</ModalsContextProvider>
|
||||
</UserProvider>
|
||||
</EditorProviders>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,7 +156,7 @@ describe('<MessageGroup />', function () {
|
||||
expect(screen.queryByText('(edited)')).to.not.exist
|
||||
})
|
||||
|
||||
it('renders message being edited with textarea and action buttons', function () {
|
||||
it('renders message being edited with a CodeMirror input and action buttons', function () {
|
||||
const messageBeingEdited: MessageType = {
|
||||
content: 'original message content',
|
||||
user: currentUser,
|
||||
@@ -166,8 +170,8 @@ describe('<MessageGroup />', function () {
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
const textarea = screen.getByDisplayValue('original message content')
|
||||
expect(textarea.tagName.toLowerCase()).to.equal('textarea')
|
||||
const editor = screen.getByRole('textbox', { name: 'Edit message' })
|
||||
expect(editor.textContent).to.contain('original message content')
|
||||
|
||||
screen.getByRole('button', { name: 'Cancel' })
|
||||
screen.getByRole('button', { name: 'Save' })
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import MessageInput from '@/features/chat/components/message-input'
|
||||
import {
|
||||
EditorProviders,
|
||||
makeProjectProvider,
|
||||
} from '../../../helpers/editor-providers'
|
||||
import { mockProject } from '../../source-editor/helpers/mock-project'
|
||||
|
||||
describe('<MessageInput />', function () {
|
||||
const INPUT_LABEL = 'Send a message to your collaborators…'
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
||||
})
|
||||
|
||||
function mountInput() {
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
providers={{ ProjectProvider: makeProjectProvider(mockProject()) }}
|
||||
>
|
||||
<MessageInput
|
||||
sendMessage={cy.stub().as('sendMessage')}
|
||||
resetUnreadMessages={cy.stub().as('resetUnreadMessages')}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
}
|
||||
|
||||
it('renders an accessible message input', function () {
|
||||
mountInput()
|
||||
cy.findByRole('textbox', { name: INPUT_LABEL }).should('exist')
|
||||
})
|
||||
|
||||
it('sends a message after typing and hitting enter', function () {
|
||||
mountInput()
|
||||
cy.findByRole('textbox', { name: INPUT_LABEL }).type('hello world{enter}')
|
||||
cy.get('@sendMessage').should('have.been.calledOnceWith', 'hello world')
|
||||
})
|
||||
|
||||
it('clears the input after sending a message', function () {
|
||||
mountInput()
|
||||
cy.findByRole('textbox', { name: INPUT_LABEL }).type('hello world{enter}')
|
||||
// The input shows its placeholder text when empty, so assert the sent
|
||||
// message is gone rather than expecting exactly-empty text content.
|
||||
cy.findByRole('textbox', { name: INPUT_LABEL }).should(
|
||||
'not.contain',
|
||||
'hello world'
|
||||
)
|
||||
})
|
||||
|
||||
it('resets the number of unread messages when the input is focused', function () {
|
||||
mountInput()
|
||||
cy.findByRole('textbox', { name: INPUT_LABEL }).focus()
|
||||
cy.get('@resetUnreadMessages').should('have.been.called')
|
||||
})
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { screen, render, fireEvent } from '@testing-library/react'
|
||||
|
||||
import MessageInput from '../../../../../frontend/js/features/chat/components/message-input'
|
||||
|
||||
describe('<MessageInput />', function () {
|
||||
let resetUnreadMessages: () => void
|
||||
let sendMessage: (content: string) => void
|
||||
|
||||
beforeEach(function () {
|
||||
resetUnreadMessages = sinon.stub()
|
||||
sendMessage = sinon.stub()
|
||||
})
|
||||
|
||||
it('renders successfully', function () {
|
||||
render(
|
||||
<MessageInput
|
||||
sendMessage={sendMessage}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
)
|
||||
|
||||
screen.getByLabelText('Send a message to your collaborators…')
|
||||
})
|
||||
|
||||
it('sends a message after typing and hitting enter', function () {
|
||||
render(
|
||||
<MessageInput
|
||||
sendMessage={sendMessage}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'hello world' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(sendMessage).to.be.calledOnce
|
||||
expect(sendMessage).to.be.calledWith('hello world')
|
||||
})
|
||||
|
||||
it('resets the number of unread messages after clicking on the input', function () {
|
||||
render(
|
||||
<MessageInput
|
||||
sendMessage={sendMessage}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
'Send a message to your collaborators…'
|
||||
)
|
||||
|
||||
fireEvent.click(input)
|
||||
expect(resetUnreadMessages).to.be.calledOnce
|
||||
})
|
||||
})
|
||||
@@ -517,13 +517,13 @@ describe('<ReviewPanel />', function () {
|
||||
it('can add comment', function () {
|
||||
cy.get('@add-comment-button').click({ scrollBehavior: false })
|
||||
cy.get('@review-panel').within(() => {
|
||||
// TODO: Fix selector
|
||||
cy.get('.review-panel-add-comment-textarea').type(
|
||||
'a new comment{enter}',
|
||||
{
|
||||
scrollBehavior: false,
|
||||
}
|
||||
)
|
||||
// The add-comment editor is the CM6 input that is not a reply/edit
|
||||
// input (those also carry the review-panel-comment-input class).
|
||||
cy.get(
|
||||
'.review-panel-add-comment-editor:not(.review-panel-comment-input) .cm-content'
|
||||
).type('a new comment{enter}', {
|
||||
scrollBehavior: false,
|
||||
})
|
||||
})
|
||||
cy.wait('@addNewComment')
|
||||
// TODO : Figure out a way to plumb the websocket response back through
|
||||
@@ -565,7 +565,9 @@ describe('<ReviewPanel />', function () {
|
||||
scrollBehavior: false,
|
||||
})
|
||||
cy.get('@review-panel').within(() => {
|
||||
cy.get('.review-panel-add-comment-textarea').should('exist')
|
||||
cy.get(
|
||||
'.review-panel-add-comment-editor:not(.review-panel-comment-input)'
|
||||
).should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
import { expect } from 'chai'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MentionBadge } from '@/features/review-panel/components/mention-badge'
|
||||
import { MentionBadge } from '@/shared/components/mention-badge'
|
||||
import {
|
||||
ChangesUsersContext,
|
||||
ChangesUsers,
|
||||
} from '@/features/review-panel/context/changes-users-context'
|
||||
} from '@/shared/context/changes-users-context'
|
||||
import { UserId } from '@ol-types/user'
|
||||
|
||||
const userId = 'aabbccddeeff00112233aabb' as UserId
|
||||
@@ -73,6 +73,6 @@ describe('<MentionBadge />', function () {
|
||||
renderMentionBadge(users)
|
||||
const badge = screen.getByText('@Jane Doe')
|
||||
expect(badge.tagName).to.equal('SPAN')
|
||||
expect(badge.classList.contains('review-panel-mention')).to.be.true
|
||||
expect(badge.classList.contains('mention-badge')).to.be.true
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { expect } from 'chai'
|
||||
import {
|
||||
parseMentions,
|
||||
sliceMentionSegments,
|
||||
} from '@/features/review-panel/utils/parse-mentions'
|
||||
} from '@/shared/utils/parse-mentions'
|
||||
import { UserId } from '@ol-types/user'
|
||||
|
||||
describe('parseMentions', function () {
|
||||
Reference in New Issue
Block a user