Merge pull request #13573 from overleaf/ii-review-panel-migration-comment-entry

[web] Create comment entries

GitOrigin-RevId: 7f3fbe672d18d57a0f5e683e5456ea79ed295e2d
This commit is contained in:
ilkin-overleaf 2023-06-29 15:56:05 +03:00 committed by Copybot
parent 13ea6b1860
commit 9e8be31bdf
12 changed files with 579 additions and 12 deletions

View file

@ -58,6 +58,7 @@
"are_you_affiliated_with_an_institution": "",
"are_you_getting_an_undefined_control_sequence_error": "",
"are_you_still_at": "",
"are_you_sure": "",
"ascending": "",
"ask_proj_owner_to_upgrade_for_full_history": "",
"ask_proj_owner_to_upgrade_for_longer_compiles": "",
@ -425,6 +426,7 @@
"history_view_a11y_description": "",
"history_view_all": "",
"history_view_labels": "",
"hit_enter_to_reply": "",
"hotkey_add_a_comment": "",
"hotkey_autocomplete_menu": "",
"hotkey_beginning_of_document": "",
@ -621,6 +623,7 @@
"new_to_latex_look_at": "",
"newsletter": "",
"next_payment_of_x_collectected_on_y": "",
"no_comments": "",
"no_existing_password": "",
"no_folder": "",
"no_image_files_found": "",
@ -797,11 +800,13 @@
"replace_from_computer": "",
"replace_from_project_files": "",
"replace_from_url": "",
"reply": "",
"repository_name": "",
"republish": "",
"resend": "",
"resend_confirmation_email": "",
"resending_confirmation_email": "",
"resolve": "",
"resolved_comments": "",
"restore_file": "",
"restoring": "",

View file

@ -1,3 +1,4 @@
import { useMemo } from 'react'
import ChangeEntry from './entries/change-entry'
import AggregateChangeEntry from './entries/aggregate-change-entry'
import CommentEntry from './entries/comment-entry'
@ -5,9 +6,12 @@ import AddCommentEntry from './entries/add-comment-entry'
import BulkActionsEntry from './entries/bulk-actions-entry'
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
import useCodeMirrorContentHeight from '../../hooks/use-codemirror-content-height'
import { ReviewPanelEntry } from '../../../../../../types/review-panel/entry'
import { ThreadId } from '../../../../../../types/review-panel/review-panel'
function CurrentFileContainer() {
const { entries, openDocId, permissions } = useReviewPanelValueContext()
const { commentThreads, entries, openDocId, permissions, loadingThreads } =
useReviewPanelValueContext()
const contentHeight = useCodeMirrorContentHeight()
console.log('Review panel got content height', contentHeight)
@ -15,6 +19,12 @@ function CurrentFileContainer() {
const currentDocEntries =
openDocId && openDocId in entries ? entries[openDocId] : undefined
const objectEntries = useMemo(() => {
return Object.entries(currentDocEntries || {}) as Array<
[ThreadId, ReviewPanelEntry]
>
}, [currentDocEntries])
return (
<div
id="review-panel-current-file"
@ -26,8 +36,8 @@ function CurrentFileContainer() {
className="rp-entry-list-inner"
style={{ height: `${contentHeight}px` }}
>
{currentDocEntries &&
Object.entries(currentDocEntries).map(([id, entry]) => {
{openDocId &&
objectEntries.map(([id, entry]) => {
if (!entry.visible) {
return null
}
@ -40,8 +50,16 @@ function CurrentFileContainer() {
return <AggregateChangeEntry key={id} />
}
if (entry.type === 'comment') {
return <CommentEntry key={id} />
if (entry.type === 'comment' && !loadingThreads) {
return (
<CommentEntry
key={id}
docId={openDocId}
entry={entry}
entryId={id}
threads={commentThreads}
/>
)
}
if (entry.type === 'add-comment' && permissions.comment) {

View file

@ -1,7 +1,195 @@
import { useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import EntryContainer from './entry-container'
import Comment from './comment'
import EntryActions from './entry-actions'
import AutoExpandingTextArea, {
resetHeight,
} from '../../../../../shared/components/auto-expanding-text-area'
import Icon from '../../../../../shared/components/icon'
import {
useReviewPanelUpdaterFnsContext,
useReviewPanelValueContext,
} from '../../../context/review-panel/review-panel-context'
import classnames from 'classnames'
import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
import {
DocId,
ReviewPanelCommentThreads,
ThreadId,
} from '../../../../../../../types/review-panel/review-panel'
function CommentEntry() {
return <EntryContainer>Comment entry</EntryContainer>
type CommentEntryProps = {
docId: DocId
entry: ReviewPanelCommentEntry
entryId: ThreadId
threads: ReviewPanelCommentThreads
}
function CommentEntry({ docId, entry, entryId, threads }: CommentEntryProps) {
const { t } = useTranslation()
const {
permissions,
gotoEntry,
toggleReviewPanel,
resolveComment,
submitReply,
handleLayoutChange,
} = useReviewPanelValueContext()
const { setEntryHover } = useReviewPanelUpdaterFnsContext()
const [replyContent, setReplyContent] = useState('')
const [animating, setAnimating] = useState(false)
const entryDivRef = useRef<HTMLDivElement | null>(null)
const thread =
entry.thread_id in threads ? threads[entry.thread_id] : undefined
const handleEntryClick = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as Element
if (
[
'rp-entry',
'rp-comment-loaded',
'rp-comment-content',
'rp-comment-reply',
'rp-entry-metadata',
].some(className => [...target.classList].includes(className))
) {
gotoEntry(docId, entry.offset)
}
}
const handleAnimateAndCallOnResolve = () => {
setAnimating(true)
if (entryDivRef.current) {
entryDivRef.current.style.top = '0'
}
setTimeout(() => {
resolveComment(docId, entryId)
}, 350)
}
const handleCommentReplyKeyPress = (
e: React.KeyboardEvent<HTMLTextAreaElement>
) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
if (replyContent.length) {
;(e.target as HTMLTextAreaElement).blur()
submitReply(entry, replyContent)
setReplyContent('')
resetHeight(e)
}
}
}
const handleOnReply = () => {
if (replyContent.length) {
submitReply(entry, replyContent)
setReplyContent('')
}
}
if (!thread) {
return null
}
return (
<EntryContainer>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
className={classnames('rp-comment-wrapper', {
'rp-comment-wrapper-resolving': animating,
})}
onMouseEnter={() => setEntryHover(true)}
onMouseLeave={() => setEntryHover(false)}
onClick={handleEntryClick}
>
<div
className="rp-entry-callout rp-entry-callout-comment"
style={{
top: entry.screenPos
? entry.screenPos.y + entry.screenPos.height - 1 + 'px'
: undefined,
}}
/>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
className={classnames('rp-entry-indicator', {
'rp-entry-indicator-focused': entry.focused,
})}
style={{
top: entry.screenPos ? `${entry.screenPos.y}px` : undefined,
}}
onClick={toggleReviewPanel}
>
<Icon type="comment" />
</div>
<div
className={classnames('rp-entry', 'rp-entry-comment', {
'rp-entry-focused': entry.focused,
'rp-entry-comment-resolving': animating,
})}
style={{
top: entry.screenPos ? `${entry.screenPos.y}px` : undefined,
visibility: entry.visible ? 'visible' : 'hidden',
}}
ref={entryDivRef}
>
{!thread.submitting && (!thread || thread.messages.length === 0) && (
<div className="rp-loading">{t('no_comments')}</div>
)}
<div className="rp-comment-loaded">
{thread.messages.map(comment => (
<Comment
key={comment.id}
thread={thread}
threadId={entry.thread_id}
comment={comment}
/>
))}
</div>
{thread.submitting && (
<div className="rp-loading">
<Icon type="spinner" spin />
</div>
)}
{permissions.comment && (
<div className="rp-comment-reply">
<AutoExpandingTextArea
className="rp-comment-input"
onChange={e => setReplyContent(e.target.value)}
onKeyPress={handleCommentReplyKeyPress}
onClick={e => e.stopPropagation()}
onResize={handleLayoutChange}
placeholder={t('hit_enter_to_reply')}
value={replyContent}
/>
</div>
)}
<EntryActions>
{permissions.comment && permissions.write && (
<EntryActions.Button onClick={handleAnimateAndCallOnResolve}>
<Icon type="inbox" /> {t('resolve')}
</EntryActions.Button>
)}
{permissions.comment && (
<EntryActions.Button
onClick={handleOnReply}
disabled={!replyContent.length}
>
<Icon type="reply" /> {t('reply')}
</EntryActions.Button>
)}
</EntryActions>
</div>
</div>
</EntryContainer>
)
}
export default CommentEntry

View file

@ -0,0 +1,119 @@
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import AutoExpandingTextArea from '../../../../../shared/components/auto-expanding-text-area'
import { formatTime } from '../../../../utils/format-date'
import { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context'
import {
ReviewPanelCommentThread,
ReviewPanelCommentThreadMessage,
ThreadId,
} from '../../../../../../../types/review-panel/review-panel'
type CommentProps = {
thread: ReviewPanelCommentThread
threadId: ThreadId
comment: ReviewPanelCommentThreadMessage
}
function Comment({ thread, threadId, comment }: CommentProps) {
const { t } = useTranslation()
const { deleteComment, handleLayoutChange, saveEdit } =
useReviewPanelValueContext()
const [deleting, setDeleting] = useState(false)
const [editing, setEditing] = useState(false)
const handleConfirmDelete = () => {
setDeleting(true)
handleLayoutChange()
}
const handleDoDelete = () => {
setDeleting(false)
deleteComment(threadId, comment.id)
handleLayoutChange()
}
const handleCancelDelete = () => {
setDeleting(false)
handleLayoutChange()
}
const handleStartEditing = () => {
setEditing(true)
handleLayoutChange()
}
const handleSaveEditOnEnter = (
e: React.KeyboardEvent<HTMLTextAreaElement>
) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
handleSaveEdit(e)
}
}
const handleSaveEdit = (
e:
| React.FocusEvent<HTMLTextAreaElement>
| React.KeyboardEvent<HTMLTextAreaElement>
) => {
setEditing(false)
saveEdit(threadId, comment.id, (e.target as HTMLTextAreaElement).value)
}
return (
<div className="rp-comment">
<p className="rp-comment-content">
{editing ? (
<AutoExpandingTextArea
className="rp-comment-input"
onKeyPress={handleSaveEditOnEnter}
onBlur={handleSaveEdit}
onClick={e => e.stopPropagation()}
onResize={handleLayoutChange}
/>
) : (
<>
<span
className="rp-entry-user"
style={{ color: `hsl(${comment.user.hue}, 70%, 40%` }}
>
{comment.user.name}:
</span>
&nbsp;
{comment.content}
</>
)}
</p>
{!editing && (
<div className="rp-entry-metadata">
{!deleting && formatTime(comment.timestamp, 'MMM d, y h:mm a')}
{comment.user.isSelf && !deleting && (
<span className="rp-comment-actions">
&nbsp;&bull;&nbsp;
<button onClick={handleStartEditing}>{t('edit')}</button>
{thread.messages.length > 1 && (
<>
&nbsp;&bull;&nbsp;
<button onClick={handleConfirmDelete}>{t('delete')}</button>
</>
)}
</span>
)}
{comment.user.isSelf && deleting && (
<span className="rp-confim-delete">
{t('are_you_sure')}&nbsp;&bull;&nbsp;
<button type="button" onClick={handleDoDelete}>
{t('delete')}
</button>
&nbsp;&bull;&nbsp;
<button onClick={handleCancelDelete}>{t('cancel')}</button>
</span>
)}
</div>
)}
</div>
)
}
export default Comment

View file

@ -0,0 +1,19 @@
import classnames from 'classnames'
function EntryActions({
className,
...rest
}: React.ComponentPropsWithoutRef<'div'>) {
return <div className={classnames('rp-entry-actions', className)} {...rest} />
}
EntryActions.Button = function EntryActionsButton({
className,
...rest
}: React.ComponentPropsWithoutRef<'button'>) {
return (
<button className={classnames('rp-entry-button', className)} {...rest} />
)
}
export default EntryActions

View file

@ -1,20 +1,30 @@
import { useMemo, useCallback } from 'react'
import { useState, useMemo, useCallback } from 'react'
import useScopeValue from '../../../../../shared/hooks/use-scope-value'
import useScopeEventEmitter from '../../../../../shared/hooks/use-scope-event-emitter'
import { ReviewPanelState } from '../types/review-panel-state'
import { sendMB } from '../../../../../infrastructure/event-tracking'
import * as ReviewPanel from '../types/review-panel-state'
import { SubView } from '../../../../../../../types/review-panel/review-panel'
import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
function useAngularReviewPanelState(): ReviewPanelState {
const emitLayoutChange = useScopeEventEmitter('review-panel:layout', false)
const [subView, setSubView] = useScopeValue<ReviewPanel.Value<'subView'>>(
'reviewPanel.subView'
)
const [collapsed, setCollapsed] = useScopeValue<
ReviewPanel.Value<'collapsed'>
>('reviewPanel.overview.docsCollapsedState')
const [commentThreads] = useScopeValue<ReviewPanel.Value<'commentThreads'>>(
'reviewPanel.commentThreads',
true
)
const [entries] = useScopeValue<ReviewPanel.Value<'entries'>>(
'reviewPanel.entries'
)
const [loadingThreads] =
useScopeValue<ReviewPanel.Value<'loadingThreads'>>('loadingThreads')
const [permissions] =
useScopeValue<ReviewPanel.Value<'permissions'>>('permissions')
@ -50,6 +60,14 @@ function useAngularReviewPanelState(): ReviewPanelState {
const [trackChangesForGuestsAvailable] = useScopeValue<
ReviewPanel.Value<'trackChangesForGuestsAvailable'>
>('reviewPanel.trackChangesForGuestsAvailable')
const [resolveComment] =
useScopeValue<ReviewPanel.Value<'resolveComment'>>('resolveComment')
const [deleteComment] =
useScopeValue<ReviewPanel.Value<'deleteComment'>>('deleteComment')
const [gotoEntry] = useScopeValue<ReviewPanel.Value<'gotoEntry'>>('gotoEntry')
const [saveEdit] = useScopeValue<ReviewPanel.Value<'saveEdit'>>('saveEdit')
const [submitReplyAngular] =
useScopeValue<(entry: ReviewPanelCommentEntry) => void>('submitReply')
const [formattedProjectMembers] = useScopeValue<
ReviewPanel.Value<'formattedProjectMembers'>
@ -66,12 +84,36 @@ function useAngularReviewPanelState(): ReviewPanelState {
[setSubView]
)
const handleLayoutChange = useCallback(() => {
window.requestAnimationFrame(() => {
emitLayoutChange()
})
}, [emitLayoutChange])
const submitReply = useCallback(
(entry: ReviewPanelCommentEntry, replyContent: string) => {
submitReplyAngular({ ...entry, replyContent })
},
[submitReplyAngular]
)
const [entryHover, setEntryHover] = useState(false)
const values = useMemo<ReviewPanelState['values']>(
() => ({
collapsed,
commentThreads,
deleteComment,
entries,
entryHover,
gotoEntry,
handleLayoutChange,
loadingThreads,
permissions,
resolveComment,
saveEdit,
shouldCollapse,
submitReply,
subView,
wantTrackChanges,
openDocId,
@ -87,9 +129,18 @@ function useAngularReviewPanelState(): ReviewPanelState {
}),
[
collapsed,
commentThreads,
deleteComment,
entries,
entryHover,
gotoEntry,
handleLayoutChange,
loadingThreads,
permissions,
resolveComment,
saveEdit,
shouldCollapse,
submitReply,
subView,
wantTrackChanges,
openDocId,
@ -108,10 +159,11 @@ function useAngularReviewPanelState(): ReviewPanelState {
const updaterFns = useMemo<ReviewPanelState['updaterFns']>(
() => ({
handleSetSubview,
setEntryHover,
setCollapsed,
setShouldCollapse,
}),
[handleSetSubview, setCollapsed, setShouldCollapse]
[handleSetSubview, setCollapsed, setEntryHover, setShouldCollapse]
)
return { values, updaterFns }

View file

@ -1,16 +1,33 @@
import {
CommentId,
DocId,
ReviewPanelCommentThreads,
ReviewPanelEntries,
ReviewPanelPermissions,
SubView,
ThreadId,
} from '../../../../../../../types/review-panel/review-panel'
import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
export interface ReviewPanelState {
values: {
collapsed: Record<string, boolean>
commentThreads: ReviewPanelCommentThreads
deleteComment: (threadId: ThreadId, commentId: CommentId) => void
entries: ReviewPanelEntries
entryHover: boolean
gotoEntry: (docId: DocId, entryOffset: number) => void
handleLayoutChange: () => void
loadingThreads: boolean
permissions: ReviewPanelPermissions
resolveComment: (docId: DocId, entryId: ThreadId) => void
saveEdit: (
threadId: ThreadId,
commentId: CommentId,
content: string
) => void
shouldCollapse: boolean
submitReply: (entry: ReviewPanelCommentEntry, replyContent: string) => void
subView: SubView
wantTrackChanges: boolean
openDocId: DocId | null
@ -32,6 +49,7 @@ export interface ReviewPanelState {
}
updaterFns: {
handleSetSubview: (subView: SubView) => void
setEntryHover: React.Dispatch<React.SetStateAction<boolean>>
setCollapsed: React.Dispatch<
React.SetStateAction<ReviewPanelState['values']['collapsed']>
>

View file

@ -0,0 +1,73 @@
import { useEffect, useRef } from 'react'
import { callFnsInSequence } from '../../utils/functions'
import { MergeAndOverride } from '../../../../types/utils'
export const resetHeight = (
e:
| React.ChangeEvent<HTMLTextAreaElement>
| React.KeyboardEvent<HTMLTextAreaElement>
) => {
const el = e.target as HTMLTextAreaElement
window.requestAnimationFrame(() => {
const curHeight = el.offsetHeight
const fitHeight = el.scrollHeight
// clear height if text area is empty
if (!el.value.length) {
el.style.removeProperty('height')
}
// otherwise expand to fit text
else if (fitHeight > curHeight) {
el.style.height = `${fitHeight}px`
}
})
}
type AutoExpandingTextAreaProps = MergeAndOverride<
React.ComponentProps<'textarea'>,
{
onResize?: () => void
}
>
function AutoExpandingTextArea({
onChange,
onResize,
...rest
}: AutoExpandingTextAreaProps) {
const ref = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (!ref.current || !onResize || !('ResizeObserver' in window)) {
return
}
let isFirstResize = true
const resizeObserver = new ResizeObserver(() => {
// Ignore the resize that is triggered when the element is first
// inserted into the DOM
if (isFirstResize) {
isFirstResize = false
} else {
onResize()
}
})
resizeObserver.observe(ref.current)
return () => {
resizeObserver.disconnect()
}
}, [onResize])
return (
<textarea
onChange={callFnsInSequence(onChange, resetHeight)}
{...rest}
ref={ref}
/>
)
}
export default AutoExpandingTextArea

View file

@ -1294,4 +1294,18 @@ button when (@is-overleaf-light = true) {
height: 0;
transition: height 150ms;
}
.rp-entry-metadata {
button {
padding: 0;
border: 0;
background-color: transparent;
color: @ol-blue;
&:hover,
&:focus {
text-decoration: underline;
}
}
}
}

View file

@ -166,4 +166,27 @@ describe('<ReviewPanel />', function () {
})
})
})
describe('comment entries', function () {
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('shows threads and comments', function () {})
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('edits comment', function () {})
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('deletes comment', function () {})
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('cancels comment editing', function () {})
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('cancels comment deletion', function () {})
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('adds new comment (replies) to a thread', function () {})
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('resolves comment', function () {})
})
})

View file

@ -1,3 +1,5 @@
import { ThreadId } from './review-panel'
interface ReviewPanelEntryScreenPos {
y: number
height: number
@ -8,14 +10,15 @@ interface ReviewPanelBaseEntry {
visible: boolean
}
interface ReviewPanelCommentEntry extends ReviewPanelBaseEntry {
export interface ReviewPanelCommentEntry extends ReviewPanelBaseEntry {
type: 'comment'
content: string
entry_ids: string[]
focused: boolean
offset: number
screenPos: ReviewPanelEntryScreenPos
thread_id: string
thread_id: ThreadId
replyContent?: string // angular specific
}
interface ReviewPanelInsertEntry extends ReviewPanelBaseEntry {

View file

@ -10,7 +10,42 @@ export interface ReviewPanelPermissions {
comment: boolean
}
export type ReviewPanelDocEntries = Record<string, ReviewPanelEntry>
export type ThreadId = Brand<string, 'ThreadId'>
type ReviewPanelDocEntries = Record<ThreadId, ReviewPanelEntry>
export type DocId = Brand<string, 'DocId'>
export type ReviewPanelEntries = Record<DocId, ReviewPanelDocEntries>
type UserId = Brand<string, 'UserId'>
interface ReviewPanelUser {
avatar_text: string
email: string
hue: number
id: UserId
isSelf: boolean
name: string
}
export type CommentId = Brand<string, 'CommentId'>
export interface ReviewPanelCommentThreadMessage {
content: string
id: CommentId
timestamp: number
user: ReviewPanelUser
user_id: UserId
}
export interface ReviewPanelCommentThread {
messages: Array<ReviewPanelCommentThreadMessage>
// resolved: boolean
// resolved_at: number
// resolved_by_user_id: string
// resolved_by_user: ReviewPanelUser
submitting?: boolean // angular specific (to be made into a local state)
}
export type ReviewPanelCommentThreads = Record<
ThreadId,
ReviewPanelCommentThread
>