Merge pull request #13658 from overleaf/ii-review-panel-migration-change-entry

[web] Create change entries

GitOrigin-RevId: 4d49f8b02b9bfcfe470f6db21f5347080ff47562
This commit is contained in:
Davinder Singh 2023-07-05 15:07:36 +01:00 committed by Copybot
parent c165eb80b1
commit b1f246b9ba
11 changed files with 295 additions and 45 deletions

View file

@ -13,6 +13,7 @@
"about_to_enable_managed_users": "",
"about_to_leave_projects": "",
"about_to_trash_projects": "",
"accept": "",
"accepted_invite": "",
"access_denied": "",
"account_has_been_link_to_institution_account": "",
@ -790,6 +791,7 @@
"refresh_page_after_starting_free_trial": "",
"refreshing": "",
"regards": "",
"reject": "",
"relink_your_account": "",
"remote_service_error": "",
"remove": "",
@ -1031,6 +1033,8 @@
"track_changes_for_x": "",
"track_changes_is_off": "",
"track_changes_is_on": "",
"tracked_change_added": "",
"tracked_change_deleted": "",
"trash": "",
"trash_projects": "",
"trashed": "",

View file

@ -8,14 +8,25 @@ import AggregateChangeEntry from './entries/aggregate-change-entry'
import CommentEntry from './entries/comment-entry'
import AddCommentEntry from './entries/add-comment-entry'
import BulkActionsEntry from './entries/bulk-actions-entry'
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
import {
useReviewPanelUpdaterFnsContext,
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 { commentThreads, entries, openDocId, permissions, loadingThreads } =
useReviewPanelValueContext()
const {
commentThreads,
entries,
openDocId,
permissions,
loadingThreads,
users,
toggleReviewPanel,
} = useReviewPanelValueContext()
const { setEntryHover } = useReviewPanelUpdaterFnsContext()
const contentHeight = useCodeMirrorContentHeight()
console.log('Review panel got content height', contentHeight)
@ -53,7 +64,18 @@ function CurrentFileContainer() {
}
if (entry.type === 'insert' || entry.type === 'delete') {
return <ChangeEntry key={id} />
return (
<ChangeEntry
key={id}
docId={openDocId}
entry={entry}
permissions={permissions}
user={users[entry.metadata.user_id]}
onMouseEnter={setEntryHover.bind(null, true)}
onMouseLeave={setEntryHover.bind(null, false)}
onIndicatorClick={toggleReviewPanel}
/>
)
}
if (entry.type === 'aggregate-change') {
@ -69,6 +91,9 @@ function CurrentFileContainer() {
entryId={id}
permissions={permissions}
threads={commentThreads}
onMouseEnter={setEntryHover.bind(null, true)}
onMouseLeave={setEntryHover.bind(null, false)}
onIndicatorClick={toggleReviewPanel}
/>
)
}

View file

@ -1,7 +1,177 @@
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import EntryContainer from './entry-container'
import EntryActions from './entry-actions'
import Icon from '../../../../../shared/components/icon'
import { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context'
import { formatTime } from '../../../../utils/format-date'
import classnames from 'classnames'
import {
ReviewPanelDeleteEntry,
ReviewPanelInsertEntry,
} from '../../../../../../../types/review-panel/entry'
import {
ReviewPanelPermissions,
ReviewPanelUser,
} from '../../../../../../../types/review-panel/review-panel'
import { DocId } from '../../../../../../../types/project-settings'
function ChangeEntry() {
return <EntryContainer>Change entry</EntryContainer>
type ChangeEntryProps = {
docId: DocId
entry: ReviewPanelInsertEntry | ReviewPanelDeleteEntry
permissions: ReviewPanelPermissions
user: ReviewPanelUser | undefined
contentLimit?: number
onMouseEnter?: () => void
onMouseLeave?: () => void
onIndicatorClick?: () => void
}
function ChangeEntry({
docId,
entry,
permissions,
user,
contentLimit = 40,
onMouseEnter,
onMouseLeave,
onIndicatorClick,
}: ChangeEntryProps) {
const { t } = useTranslation()
const { acceptChanges, rejectChanges, handleLayoutChange, gotoEntry } =
useReviewPanelValueContext()
const [isCollapsed, setIsCollapsed] = useState(false)
const content = isCollapsed
? entry.content.substring(0, contentLimit)
: entry.content
const needsCollapsing = entry.content.length > contentLimit
const handleEntryClick = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as Element
for (const selector of [
'.rp-entry',
'.rp-entry-description',
'.rp-entry-body',
'.rp-entry-action-icon i',
]) {
if (target.matches(selector)) {
gotoEntry(docId, entry.offset)
break
}
}
}
const handleToggleCollapse = () => {
setIsCollapsed(value => !value)
handleLayoutChange()
}
return (
<EntryContainer
onClick={handleEntryClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<div
className={classnames(
'rp-entry-callout',
`rp-entry-callout-${entry.type}`
)}
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={onIndicatorClick}
>
{entry.type === 'insert' ? (
<Icon type="pencil" />
) : (
<i className="rp-icon-delete" />
)}
</div>
<div
className={classnames('rp-entry', `rp-entry-${entry.type}`, {
'rp-entry-focused': entry.focused,
})}
style={{
top: entry.screenPos ? entry.screenPos.y + 'px' : undefined,
visibility: entry.visible ? 'visible' : 'hidden',
}}
>
<div className="rp-entry-body">
<div className="rp-entry-action-icon">
{entry.type === 'insert' ? (
<Icon type="pencil" />
) : (
<i className="rp-icon-delete" />
)}
</div>
<div className="rp-entry-details">
<div className="rp-entry-description">
<span>
{entry.type === 'insert' ? (
<>
{t('tracked_change_added')}&nbsp;
<ins className="rp-content-highlight">{content}</ins>
</>
) : (
<>
{t('tracked_change_deleted')}&nbsp;
<del className="rp-content-highlight">{content}</del>
</>
)}
{needsCollapsing && (
<button
className="rp-collapse-toggle btn-inline-link"
onClick={handleToggleCollapse}
>
{isCollapsed
? `… (${t('show_all')})`
: ` (${t('show_less')})`}
</button>
)}
</span>
</div>
<div className="rp-entry-metadata">
{formatTime(entry.metadata.ts, 'MMM d, y h:mm a')}
&nbsp;&bull;&nbsp;
{user && (
<span
className="rp-entry-user"
style={{ color: `hsl(${user.hue}, 70%, 40%)` }}
>
{user.name ?? t('anonymous')}
</span>
)}
</div>
</div>
</div>
{permissions.write && (
<EntryActions>
<EntryActions.Button onClick={() => rejectChanges(entry.entry_ids)}>
<Icon type="times" /> {t('reject')}
</EntryActions.Button>
<EntryActions.Button onClick={() => acceptChanges(entry.entry_ids)}>
<Icon type="check" /> {t('accept')}
</EntryActions.Button>
</EntryActions>
)}
</div>
</EntryContainer>
)
}
export default ChangeEntry

View file

@ -7,10 +7,7 @@ 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 { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context'
import classnames from 'classnames'
import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
import {
@ -26,6 +23,9 @@ type CommentEntryProps = {
entryId: ThreadId
permissions: ReviewPanelPermissions
threads: ReviewPanelCommentThreads
onMouseEnter?: () => void
onMouseLeave?: () => void
onIndicatorClick?: () => void
}
function CommentEntry({
@ -34,17 +34,13 @@ function CommentEntry({
entryId,
permissions,
threads,
onMouseEnter,
onMouseLeave,
onIndicatorClick,
}: CommentEntryProps) {
const { t } = useTranslation()
const {
gotoEntry,
toggleReviewPanel,
resolveComment,
submitReply,
handleLayoutChange,
} = useReviewPanelValueContext()
const { setEntryHover } = useReviewPanelUpdaterFnsContext()
const { gotoEntry, resolveComment, submitReply, handleLayoutChange } =
useReviewPanelValueContext()
const [replyContent, setReplyContent] = useState('')
const [animating, setAnimating] = useState(false)
const [resolved, setResolved] = useState(false)
@ -55,16 +51,18 @@ function CommentEntry({
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))
) {
for (const selector of [
'.rp-entry',
'.rp-comment-loaded',
'.rp-comment-content',
'.rp-comment-reply',
'.rp-entry-metadata',
]) {
if (target.matches(selector)) {
gotoEntry(docId, entry.offset)
break
}
}
}
@ -109,15 +107,16 @@ function CommentEntry({
}
return (
<EntryContainer>
<EntryContainer
onClick={handleEntryClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* 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"
@ -135,7 +134,7 @@ function CommentEntry({
style={{
top: entry.screenPos ? `${entry.screenPos.y}px` : undefined,
}}
onClick={toggleReviewPanel}
onClick={onIndicatorClick}
>
<Icon type="comment" />
</div>

View file

@ -1,9 +1,7 @@
type EntryContainerProps = {
children: React.ReactNode
}
import classnames from 'classnames'
function EntryContainer({ children }: EntryContainerProps) {
return <div className="rp-entry-wrapper">{children}</div>
function EntryContainer({ className, ...rest }: React.ComponentProps<'div'>) {
return <div className={classnames('rp-entry-wrapper', className)} {...rest} />
}
export default EntryContainer

View file

@ -19,7 +19,7 @@ type OverviewFileProps = {
}
function OverviewFile({ docId, docPath }: OverviewFileProps) {
const { entries, collapsed, commentThreads, permissions } =
const { entries, collapsed, commentThreads, permissions, users } =
useReviewPanelValueContext()
const { setCollapsed } = useReviewPanelUpdaterFnsContext()
@ -77,7 +77,15 @@ function OverviewFile({ docId, docPath }: OverviewFileProps) {
<div className="rp-overview-file-entries" ref={entriesContainerRef}>
{objectEntries.map(([id, entry]) => {
if (entry.type === 'insert' || entry.type === 'delete') {
return <ChangeEntry key={id} />
return (
<ChangeEntry
key={id}
docId={docId}
entry={entry}
permissions={permissions}
user={users[entry.metadata.user_id]}
/>
)
}
if (entry.type === 'aggregate-change') {

View file

@ -33,6 +33,7 @@ function useAngularReviewPanelState(): ReviewPanelState {
const [permissions] =
useScopeValue<ReviewPanel.Value<'permissions'>>('permissions')
const [users] = useScopeValue<ReviewPanel.Value<'users'>>('users', true)
const [resolvedComments] = useScopeValue<
ReviewPanel.Value<'resolvedComments'>
>('reviewPanel.resolvedComments', true)
@ -90,6 +91,10 @@ function useAngularReviewPanelState(): ReviewPanelState {
const [refreshResolvedCommentsDropdown] = useScopeValue<
ReviewPanel.Value<'refreshResolvedCommentsDropdown'>
>('refreshResolvedCommentsDropdown')
const [acceptChanges] =
useScopeValue<ReviewPanel.Value<'acceptChanges'>>('acceptChanges')
const [rejectChanges] =
useScopeValue<ReviewPanel.Value<'rejectChanges'>>('rejectChanges')
const handleSetSubview = useCallback(
(subView: SubView) => {
@ -126,6 +131,7 @@ function useAngularReviewPanelState(): ReviewPanelState {
handleLayoutChange,
loadingThreads,
permissions,
users,
resolveComment,
resolvedComments,
saveEdit,
@ -147,6 +153,8 @@ function useAngularReviewPanelState(): ReviewPanelState {
unresolveComment,
deleteThread,
refreshResolvedCommentsDropdown,
acceptChanges,
rejectChanges,
}),
[
collapsed,
@ -159,6 +167,7 @@ function useAngularReviewPanelState(): ReviewPanelState {
handleLayoutChange,
loadingThreads,
permissions,
users,
resolveComment,
resolvedComments,
saveEdit,
@ -180,6 +189,8 @@ function useAngularReviewPanelState(): ReviewPanelState {
unresolveComment,
deleteThread,
refreshResolvedCommentsDropdown,
acceptChanges,
rejectChanges,
]
)

View file

@ -3,6 +3,7 @@ import {
ReviewPanelCommentThreads,
ReviewPanelEntries,
ReviewPanelPermissions,
ReviewPanelUsers,
SubView,
ThreadId,
} from '../../../../../../../types/review-panel/review-panel'
@ -24,6 +25,7 @@ export interface ReviewPanelState {
handleLayoutChange: () => void
loadingThreads: boolean
permissions: ReviewPanelPermissions
users: ReviewPanelUsers
resolveComment: (docId: DocId, entryId: ThreadId) => void
resolvedComments: ReviewPanelEntries
saveEdit: (
@ -55,6 +57,8 @@ export interface ReviewPanelState {
unresolveComment: (threadId: ThreadId) => void
deleteThread: (_entryId: unknown, docId: DocId, threadId: ThreadId) => void
refreshResolvedCommentsDropdown: () => Promise<void>
acceptChanges: (entryIds: unknown) => void
rejectChanges: (entryIds: unknown) => void
}
updaterFns: {
handleSetSubview: (subView: SubView) => void

View file

@ -193,6 +193,20 @@ describe('<ReviewPanel />', function () {
it.skip('resolves comment', function () {})
})
describe('change entries', function () {
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('renders inserted entries', function () {})
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('renders deleted entries', function () {})
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('accepts change', function () {})
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('rejects change', function () {})
})
describe('overview mode', function () {
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('shows list of files changed', function () {})

View file

@ -1,4 +1,4 @@
import { ThreadId } from './review-panel'
import { ThreadId, UserId } from './review-panel'
interface ReviewPanelEntryScreenPos {
y: number
@ -14,19 +14,35 @@ interface ReviewPanelBaseEntry {
export interface ReviewPanelCommentEntry extends ReviewPanelBaseEntry {
type: 'comment'
content: string
entry_ids: string[]
entry_ids: ThreadId[]
focused: boolean
screenPos: ReviewPanelEntryScreenPos
thread_id: ThreadId
replyContent?: string // angular specific
}
interface ReviewPanelInsertEntry extends ReviewPanelBaseEntry {
export interface ReviewPanelInsertEntry extends ReviewPanelBaseEntry {
type: 'insert'
content: string
entry_ids: ThreadId[]
metadata: {
ts: Date
user_id: UserId
}
screenPos: ReviewPanelEntryScreenPos
focused?: boolean
}
interface ReviewPanelDeleteEntry extends ReviewPanelBaseEntry {
export interface ReviewPanelDeleteEntry extends ReviewPanelBaseEntry {
type: 'delete'
content: string
entry_ids: ThreadId[]
metadata: {
ts: Date
user_id: UserId
}
screenPos: ReviewPanelEntryScreenPos
focused?: boolean
}
interface ReviewPanelAggregateChangeEntry extends ReviewPanelBaseEntry {

View file

@ -27,6 +27,7 @@ export interface ReviewPanelUser {
isSelf: boolean
name: string
}
export type ReviewPanelUsers = Record<UserId, ReviewPanelUser>
export type CommentId = Brand<string, 'CommentId'>