mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-14 20:40:17 -05:00
Fix accept changes request for new review panel (#20956)
* Fix accept changes request for new review panel * Implement pessimistic UI updates for new review panel (#20986) * Implement pessimistic UI updates for new review panel * deleteThread, reopenThread handlers * use finally * handleSubmit in useCallback GitOrigin-RevId: 358181a6b5601ad1b3579e001564dfa4da67a81d
This commit is contained in:
parent
42a199043a
commit
4a3eed35d6
13 changed files with 313 additions and 137 deletions
|
@ -29,6 +29,8 @@
|
||||||
"accept_all": "",
|
"accept_all": "",
|
||||||
"accept_and_continue": "",
|
"accept_and_continue": "",
|
||||||
"accept_change": "",
|
"accept_change": "",
|
||||||
|
"accept_change_error_description": "",
|
||||||
|
"accept_change_error_title": "",
|
||||||
"accept_invitation": "",
|
"accept_invitation": "",
|
||||||
"accept_or_reject_each_changes_individually": "",
|
"accept_or_reject_each_changes_individually": "",
|
||||||
"accept_terms_and_conditions": "",
|
"accept_terms_and_conditions": "",
|
||||||
|
@ -53,6 +55,8 @@
|
||||||
"add_another_token": "",
|
"add_another_token": "",
|
||||||
"add_comma_separated_emails_help": "",
|
"add_comma_separated_emails_help": "",
|
||||||
"add_comment": "",
|
"add_comment": "",
|
||||||
|
"add_comment_error_message": "",
|
||||||
|
"add_comment_error_title": "",
|
||||||
"add_company_details": "",
|
"add_company_details": "",
|
||||||
"add_email_address": "",
|
"add_email_address": "",
|
||||||
"add_email_to_claim_features": "",
|
"add_email_to_claim_features": "",
|
||||||
|
@ -303,6 +307,8 @@
|
||||||
"delete_authentication_token_info": "",
|
"delete_authentication_token_info": "",
|
||||||
"delete_certificate": "",
|
"delete_certificate": "",
|
||||||
"delete_comment": "",
|
"delete_comment": "",
|
||||||
|
"delete_comment_error_message": "",
|
||||||
|
"delete_comment_error_title": "",
|
||||||
"delete_comment_message": "",
|
"delete_comment_message": "",
|
||||||
"delete_comment_thread": "",
|
"delete_comment_thread": "",
|
||||||
"delete_comment_thread_message": "",
|
"delete_comment_thread_message": "",
|
||||||
|
@ -384,6 +390,8 @@
|
||||||
"easily_import_and_sync_your_references": "",
|
"easily_import_and_sync_your_references": "",
|
||||||
"easily_manage_your_project_files_everywhere": "",
|
"easily_manage_your_project_files_everywhere": "",
|
||||||
"edit": "",
|
"edit": "",
|
||||||
|
"edit_comment_error_message": "",
|
||||||
|
"edit_comment_error_title": "",
|
||||||
"edit_dictionary": "",
|
"edit_dictionary": "",
|
||||||
"edit_dictionary_empty": "",
|
"edit_dictionary_empty": "",
|
||||||
"edit_dictionary_remove": "",
|
"edit_dictionary_remove": "",
|
||||||
|
@ -1181,6 +1189,8 @@
|
||||||
"rename": "",
|
"rename": "",
|
||||||
"rename_project": "",
|
"rename_project": "",
|
||||||
"reopen": "",
|
"reopen": "",
|
||||||
|
"reopen_comment_error_message": "",
|
||||||
|
"reopen_comment_error_title": "",
|
||||||
"replace_figure": "",
|
"replace_figure": "",
|
||||||
"replace_from_another_project": "",
|
"replace_from_another_project": "",
|
||||||
"replace_from_computer": "",
|
"replace_from_computer": "",
|
||||||
|
@ -1201,6 +1211,8 @@
|
||||||
"resize": "",
|
"resize": "",
|
||||||
"resolve": "",
|
"resolve": "",
|
||||||
"resolve_comment": "",
|
"resolve_comment": "",
|
||||||
|
"resolve_comment_error_message": "",
|
||||||
|
"resolve_comment_error_title": "",
|
||||||
"resolved_comments": "",
|
"resolved_comments": "",
|
||||||
"restore": "",
|
"restore": "",
|
||||||
"restore_file": "",
|
"restore_file": "",
|
||||||
|
|
|
@ -13,6 +13,8 @@ import { Button } from 'react-bootstrap'
|
||||||
import { ReviewPanelEntry } from './review-panel-entry'
|
import { ReviewPanelEntry } from './review-panel-entry'
|
||||||
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
||||||
import { Decoration } from '@codemirror/view'
|
import { Decoration } from '@codemirror/view'
|
||||||
|
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||||
|
import { debugConsole } from '@/utils/debugging'
|
||||||
|
|
||||||
export const ReviewPanelAddComment: FC<{
|
export const ReviewPanelAddComment: FC<{
|
||||||
docId: string
|
docId: string
|
||||||
|
@ -25,8 +27,8 @@ export const ReviewPanelAddComment: FC<{
|
||||||
const view = useCodeMirrorViewContext()
|
const view = useCodeMirrorViewContext()
|
||||||
const state = useCodeMirrorStateContext()
|
const state = useCodeMirrorStateContext()
|
||||||
const { addComment } = useThreadsActionsContext()
|
const { addComment } = useThreadsActionsContext()
|
||||||
const [error, setError] = useState<Error>()
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const { showGenericMessageModal } = useModalsContext()
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
|
@ -35,22 +37,27 @@ export const ReviewPanelAddComment: FC<{
|
||||||
}, [view, value])
|
}, [view, value])
|
||||||
|
|
||||||
const submitForm = useCallback(
|
const submitForm = useCallback(
|
||||||
message => {
|
async message => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
|
|
||||||
const content = view.state.sliceDoc(from, to)
|
const content = view.state.sliceDoc(from, to)
|
||||||
|
|
||||||
addComment(from, content, message)
|
try {
|
||||||
.catch(setError)
|
await addComment(from, content, message)
|
||||||
.finally(() => setSubmitting(false))
|
handleClose()
|
||||||
|
view.dispatch({
|
||||||
view.dispatch({
|
selection: EditorSelection.cursor(view.state.selection.main.anchor),
|
||||||
selection: EditorSelection.cursor(view.state.selection.main.anchor),
|
})
|
||||||
})
|
} catch (err) {
|
||||||
|
debugConsole.error(err)
|
||||||
handleClose()
|
showGenericMessageModal(
|
||||||
|
t('add_comment_error_title'),
|
||||||
|
t('add_comment_error_message')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setSubmitting(false)
|
||||||
},
|
},
|
||||||
[addComment, view, handleClose, from, to]
|
[addComment, view, handleClose, from, to, showGenericMessageModal, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { handleChange, handleKeyPress, content } =
|
const { handleChange, handleKeyPress, content } =
|
||||||
|
@ -123,6 +130,7 @@ export const ReviewPanelAddComment: FC<{
|
||||||
t: value.spec.id as ThreadId,
|
t: value.spec.id as ThreadId,
|
||||||
}}
|
}}
|
||||||
selectLineOnFocus={false}
|
selectLineOnFocus={false}
|
||||||
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
className="review-panel-entry-content"
|
className="review-panel-entry-content"
|
||||||
|
@ -158,7 +166,6 @@ export const ReviewPanelAddComment: FC<{
|
||||||
{t('comment')}
|
{t('comment')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{error && <div>{error.message}</div>}
|
|
||||||
</form>
|
</form>
|
||||||
</ReviewPanelEntry>
|
</ReviewPanelEntry>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { memo } from 'react'
|
import { memo, useCallback, useState } from 'react'
|
||||||
import { useRangesActionsContext } from '../context/ranges-context'
|
import { useRangesActionsContext } from '../context/ranges-context'
|
||||||
import {
|
import {
|
||||||
Change,
|
Change,
|
||||||
|
@ -15,6 +15,7 @@ import { formatTimeBasedOnYear } from '@/features/utils/format-date'
|
||||||
import { useChangesUsersContext } from '../context/changes-users-context'
|
import { useChangesUsersContext } from '../context/changes-users-context'
|
||||||
import { ReviewPanelChangeUser } from './review-panel-change-user'
|
import { ReviewPanelChangeUser } from './review-panel-change-user'
|
||||||
import { ReviewPanelEntry } from './review-panel-entry'
|
import { ReviewPanelEntry } from './review-panel-entry'
|
||||||
|
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||||
|
|
||||||
export const ReviewPanelChange = memo<{
|
export const ReviewPanelChange = memo<{
|
||||||
change: Change<EditOperation>
|
change: Change<EditOperation>
|
||||||
|
@ -42,6 +43,27 @@ export const ReviewPanelChange = memo<{
|
||||||
const { acceptChanges, rejectChanges } = useRangesActionsContext()
|
const { acceptChanges, rejectChanges } = useRangesActionsContext()
|
||||||
const permissions = usePermissionsContext()
|
const permissions = usePermissionsContext()
|
||||||
const changesUsers = useChangesUsersContext()
|
const changesUsers = useChangesUsersContext()
|
||||||
|
const { showGenericMessageModal } = useModalsContext()
|
||||||
|
|
||||||
|
const [accepting, setAccepting] = useState(false)
|
||||||
|
|
||||||
|
const acceptHandler = useCallback(async () => {
|
||||||
|
setAccepting(true)
|
||||||
|
try {
|
||||||
|
if (aggregate) {
|
||||||
|
await acceptChanges(change.id, aggregate.id)
|
||||||
|
} else {
|
||||||
|
await acceptChanges(change.id)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showGenericMessageModal(
|
||||||
|
t('accept_change_error_title'),
|
||||||
|
t('accept_change_error_description')
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setAccepting(false)
|
||||||
|
}
|
||||||
|
}, [acceptChanges, aggregate, change.id, showGenericMessageModal, t])
|
||||||
|
|
||||||
if (!changesUsers) {
|
if (!changesUsers) {
|
||||||
// if users are not loaded yet, do not show "Unknown" user
|
// if users are not loaded yet, do not show "Unknown" user
|
||||||
|
@ -61,6 +83,7 @@ export const ReviewPanelChange = memo<{
|
||||||
position={change.op.p}
|
position={change.op.p}
|
||||||
docId={docId}
|
docId={docId}
|
||||||
hoverRanges={hoverRanges}
|
hoverRanges={hoverRanges}
|
||||||
|
disabled={accepting}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="review-panel-entry-indicator"
|
className="review-panel-entry-indicator"
|
||||||
|
@ -92,14 +115,7 @@ export const ReviewPanelChange = memo<{
|
||||||
description={t('accept_change')}
|
description={t('accept_change')}
|
||||||
tooltipProps={{ className: 'review-panel-tooltip' }}
|
tooltipProps={{ className: 'review-panel-tooltip' }}
|
||||||
>
|
>
|
||||||
<Button
|
<Button onClick={acceptHandler} bsStyle={null}>
|
||||||
onClick={() =>
|
|
||||||
aggregate
|
|
||||||
? acceptChanges(change.id, aggregate.id)
|
|
||||||
: acceptChanges(change.id)
|
|
||||||
}
|
|
||||||
bsStyle={null}
|
|
||||||
>
|
|
||||||
<MaterialIcon
|
<MaterialIcon
|
||||||
type="check"
|
type="check"
|
||||||
className="review-panel-entry-actions-icon"
|
className="review-panel-entry-actions-icon"
|
||||||
|
|
|
@ -1,100 +1,98 @@
|
||||||
import { Dispatch, memo, SetStateAction, useCallback, useState } from 'react'
|
import { memo, useCallback } from 'react'
|
||||||
import { Change, CommentOperation } from '../../../../../types/change'
|
import { Change, CommentOperation } from '../../../../../types/change'
|
||||||
import { ReviewPanelMessage } from './review-panel-message'
|
import { ReviewPanelMessage } from './review-panel-message'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import { useThreadsContext } from '../context/threads-context'
|
||||||
useThreadsActionsContext,
|
|
||||||
useThreadsContext,
|
|
||||||
} from '../context/threads-context'
|
|
||||||
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
|
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
|
||||||
import ReviewPanelResolvedMessage from './review-panel-resolved-message'
|
import ReviewPanelResolvedMessage from './review-panel-resolved-message'
|
||||||
import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread'
|
import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread'
|
||||||
import useSubmittableTextInput from '../hooks/use-submittable-text-input'
|
import useSubmittableTextInput from '../hooks/use-submittable-text-input'
|
||||||
|
import { CommentId } from '../../../../../types/review-panel/review-panel'
|
||||||
|
|
||||||
export const ReviewPanelCommentContent = memo<{
|
export const ReviewPanelCommentContent = memo<{
|
||||||
comment: Change<CommentOperation>
|
comment: Change<CommentOperation>
|
||||||
isResolved: boolean
|
isResolved: boolean
|
||||||
|
onEdit?: (commentId: CommentId, content: string) => Promise<void>
|
||||||
|
onReply?: (content: string) => Promise<void>
|
||||||
|
onDelete?: (commentId: CommentId) => Promise<void>
|
||||||
|
onResolve?: () => Promise<void>
|
||||||
onLeave?: () => void
|
onLeave?: () => void
|
||||||
onEnter?: () => void
|
onEnter?: () => void
|
||||||
}>(({ comment, isResolved, onLeave, onEnter }) => {
|
}>(
|
||||||
const { t } = useTranslation()
|
({
|
||||||
const [submitting, setSubmitting] = useState(false)
|
comment,
|
||||||
const [error, setError] = useState<Error>()
|
isResolved,
|
||||||
const threads = useThreadsContext()
|
onResolve,
|
||||||
const { resolveThread, addMessage } = useThreadsActionsContext()
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
onReply,
|
||||||
|
onLeave,
|
||||||
|
onEnter,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const threads = useThreadsContext()
|
||||||
|
|
||||||
const handleSubmitReply = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(content: string, setContent: Dispatch<SetStateAction<string>>) => {
|
(content, setContent) => onReply?.(content).then(() => setContent('')),
|
||||||
setSubmitting(true)
|
[onReply]
|
||||||
addMessage(comment.op.t, content)
|
)
|
||||||
.then(() => {
|
|
||||||
setContent('')
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
setError(error)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setSubmitting(false)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[addMessage, comment.op.t]
|
|
||||||
)
|
|
||||||
|
|
||||||
const { handleChange, handleKeyPress, content } =
|
const { handleChange, handleKeyPress, content } =
|
||||||
useSubmittableTextInput(handleSubmitReply)
|
useSubmittableTextInput(handleSubmit)
|
||||||
|
|
||||||
const thread = threads?.[comment.op.t]
|
const thread = threads?.[comment.op.t]
|
||||||
if (!thread) {
|
if (!thread) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="review-panel-entry-content"
|
className="review-panel-entry-content"
|
||||||
onMouseEnter={onEnter}
|
onMouseEnter={onEnter}
|
||||||
onMouseLeave={onLeave}
|
onMouseLeave={onLeave}
|
||||||
>
|
>
|
||||||
{thread.messages.map((message, i) => {
|
{thread.messages.map((message, i) => {
|
||||||
const isReply = i !== 0
|
const isReply = i !== 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={message.id} className="review-panel-comment-wrapper">
|
<div key={message.id} className="review-panel-comment-wrapper">
|
||||||
{isReply && <div className="review-panel-comment-reply-divider" />}
|
{isReply && (
|
||||||
<ReviewPanelMessage
|
<div className="review-panel-comment-reply-divider" />
|
||||||
message={message}
|
)}
|
||||||
threadId={comment.op.t}
|
<ReviewPanelMessage
|
||||||
isReply={isReply}
|
message={message}
|
||||||
hasReplies={!isReply && thread.messages.length > 1}
|
isReply={isReply}
|
||||||
onResolve={() => resolveThread(comment.op.t)}
|
hasReplies={!isReply && thread.messages.length > 1}
|
||||||
isThreadResolved={isResolved}
|
onResolve={onResolve}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
isThreadResolved={isResolved}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{isResolved && (
|
||||||
|
<div className="review-panel-comment-wrapper">
|
||||||
|
<div className="review-panel-comment-reply-divider" />
|
||||||
|
<ReviewPanelResolvedMessage
|
||||||
|
thread={thread as ReviewPanelResolvedCommentThread}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
})}
|
|
||||||
|
|
||||||
{isResolved && (
|
{!isResolved && (
|
||||||
<div className="review-panel-comment-wrapper">
|
<AutoExpandingTextArea
|
||||||
<div className="review-panel-comment-reply-divider" />
|
name="content"
|
||||||
<ReviewPanelResolvedMessage
|
className="review-panel-comment-input"
|
||||||
thread={thread as ReviewPanelResolvedCommentThread}
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyPress}
|
||||||
|
placeholder={t('reply')}
|
||||||
|
value={content}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
)
|
||||||
{!isResolved && (
|
}
|
||||||
<AutoExpandingTextArea
|
)
|
||||||
name="content"
|
|
||||||
className="review-panel-comment-input"
|
|
||||||
onChange={handleChange}
|
|
||||||
onKeyDown={handleKeyPress}
|
|
||||||
placeholder={t('reply')}
|
|
||||||
value={content}
|
|
||||||
disabled={submitting}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <div>{error.message}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
ReviewPanelCommentContent.displayName = 'ReviewPanelCommentContent'
|
ReviewPanelCommentContent.displayName = 'ReviewPanelCommentContent'
|
||||||
|
|
|
@ -1,10 +1,17 @@
|
||||||
import { memo } from 'react'
|
import { memo, useCallback, useState } from 'react'
|
||||||
import { Change, CommentOperation } from '../../../../../types/change'
|
import { Change, CommentOperation } from '../../../../../types/change'
|
||||||
import { useThreadsContext } from '../context/threads-context'
|
import {
|
||||||
|
useThreadsActionsContext,
|
||||||
|
useThreadsContext,
|
||||||
|
} from '../context/threads-context'
|
||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
import { ReviewPanelEntry } from './review-panel-entry'
|
import { ReviewPanelEntry } from './review-panel-entry'
|
||||||
import MaterialIcon from '@/shared/components/material-icon'
|
import MaterialIcon from '@/shared/components/material-icon'
|
||||||
import { ReviewPanelCommentContent } from './review-panel-comment-content'
|
import { ReviewPanelCommentContent } from './review-panel-comment-content'
|
||||||
|
import { CommentId } from '../../../../../types/review-panel/review-panel'
|
||||||
|
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { debugConsole } from '@/utils/debugging'
|
||||||
|
|
||||||
export const ReviewPanelComment = memo<{
|
export const ReviewPanelComment = memo<{
|
||||||
comment: Change<CommentOperation>
|
comment: Change<CommentOperation>
|
||||||
|
@ -16,6 +23,82 @@ export const ReviewPanelComment = memo<{
|
||||||
hovered?: boolean
|
hovered?: boolean
|
||||||
}>(({ comment, top, hovered, onEnter, onLeave, docId, hoverRanges }) => {
|
}>(({ comment, top, hovered, onEnter, onLeave, docId, hoverRanges }) => {
|
||||||
const threads = useThreadsContext()
|
const threads = useThreadsContext()
|
||||||
|
const { resolveThread, editMessage, deleteMessage, addMessage } =
|
||||||
|
useThreadsActionsContext()
|
||||||
|
const { showGenericMessageModal } = useModalsContext()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const [processing, setProcessing] = useState(false)
|
||||||
|
|
||||||
|
const handleResolveComment = useCallback(async () => {
|
||||||
|
setProcessing(true)
|
||||||
|
try {
|
||||||
|
await resolveThread(comment.op.t)
|
||||||
|
} catch (err) {
|
||||||
|
debugConsole.error(err)
|
||||||
|
showGenericMessageModal(
|
||||||
|
t('resolve_comment_error_title'),
|
||||||
|
t('resolve_comment_error_message')
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setProcessing(false)
|
||||||
|
}
|
||||||
|
}, [comment.op.t, resolveThread, showGenericMessageModal, t])
|
||||||
|
|
||||||
|
const handleEditMessage = useCallback(
|
||||||
|
async (commentId: CommentId, content: string) => {
|
||||||
|
setProcessing(true)
|
||||||
|
try {
|
||||||
|
await editMessage(comment.op.t, commentId, content)
|
||||||
|
} catch (err) {
|
||||||
|
debugConsole.error(err)
|
||||||
|
showGenericMessageModal(
|
||||||
|
t('edit_comment_error_title'),
|
||||||
|
t('edit_comment_error_message')
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setProcessing(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[comment.op.t, editMessage, showGenericMessageModal, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDeleteMessage = useCallback(
|
||||||
|
async (commentId: CommentId) => {
|
||||||
|
setProcessing(true)
|
||||||
|
try {
|
||||||
|
await deleteMessage(comment.op.t, commentId)
|
||||||
|
} catch (err) {
|
||||||
|
debugConsole.error(err)
|
||||||
|
showGenericMessageModal(
|
||||||
|
t('delete_comment_error_title'),
|
||||||
|
t('delete_comment_error_message')
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setProcessing(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[comment.op.t, deleteMessage, showGenericMessageModal, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSubmitReply = useCallback(
|
||||||
|
async (content: string) => {
|
||||||
|
setProcessing(true)
|
||||||
|
try {
|
||||||
|
await addMessage(comment.op.t, content)
|
||||||
|
} catch (err) {
|
||||||
|
debugConsole.error(err)
|
||||||
|
showGenericMessageModal(
|
||||||
|
t('add_comment_error_title'),
|
||||||
|
t('add_comment_error_message')
|
||||||
|
)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
setProcessing(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addMessage, comment.op.t, showGenericMessageModal, t]
|
||||||
|
)
|
||||||
|
|
||||||
const thread = threads?.[comment.op.t]
|
const thread = threads?.[comment.op.t]
|
||||||
if (!thread || thread.resolved || thread.messages.length === 0) {
|
if (!thread || thread.resolved || thread.messages.length === 0) {
|
||||||
|
@ -33,6 +116,7 @@ export const ReviewPanelComment = memo<{
|
||||||
op={comment.op}
|
op={comment.op}
|
||||||
position={comment.op.p}
|
position={comment.op.p}
|
||||||
hoverRanges={hoverRanges}
|
hoverRanges={hoverRanges}
|
||||||
|
disabled={processing}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="review-panel-entry-indicator"
|
className="review-panel-entry-indicator"
|
||||||
|
@ -46,6 +130,10 @@ export const ReviewPanelComment = memo<{
|
||||||
isResolved={false}
|
isResolved={false}
|
||||||
onLeave={onLeave}
|
onLeave={onLeave}
|
||||||
onEnter={onEnter}
|
onEnter={onEnter}
|
||||||
|
onResolve={handleResolveComment}
|
||||||
|
onEdit={handleEditMessage}
|
||||||
|
onDelete={handleDeleteMessage}
|
||||||
|
onReply={handleSubmitReply}
|
||||||
/>
|
/>
|
||||||
</ReviewPanelEntry>
|
</ReviewPanelEntry>
|
||||||
)
|
)
|
||||||
|
|
|
@ -21,6 +21,7 @@ export const ReviewPanelEntry: FC<{
|
||||||
className?: string
|
className?: string
|
||||||
selectLineOnFocus?: boolean
|
selectLineOnFocus?: boolean
|
||||||
hoverRanges?: boolean
|
hoverRanges?: boolean
|
||||||
|
disabled?: boolean
|
||||||
}> = ({
|
}> = ({
|
||||||
children,
|
children,
|
||||||
position,
|
position,
|
||||||
|
@ -30,6 +31,7 @@ export const ReviewPanelEntry: FC<{
|
||||||
selectLineOnFocus = true,
|
selectLineOnFocus = true,
|
||||||
docId,
|
docId,
|
||||||
hoverRanges = true,
|
hoverRanges = true,
|
||||||
|
disabled,
|
||||||
}) => {
|
}) => {
|
||||||
const state = useCodeMirrorStateContext()
|
const state = useCodeMirrorStateContext()
|
||||||
const view = useCodeMirrorViewContext()
|
const view = useCodeMirrorViewContext()
|
||||||
|
@ -95,6 +97,7 @@ export const ReviewPanelEntry: FC<{
|
||||||
{
|
{
|
||||||
'review-panel-entry-focused': focused,
|
'review-panel-entry-focused': focused,
|
||||||
'review-panel-entry-highlighted': highlighted,
|
'review-panel-entry-highlighted': highlighted,
|
||||||
|
'review-panel-entry-disabled': disabled,
|
||||||
},
|
},
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { FC, useCallback, useState } from 'react'
|
import { FC, useCallback, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
CommentId,
|
||||||
ReviewPanelCommentThreadMessage,
|
ReviewPanelCommentThreadMessage,
|
||||||
ThreadId,
|
|
||||||
} from '../../../../../types/review-panel/review-panel'
|
} from '../../../../../types/review-panel/review-panel'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useThreadsActionsContext } from '../context/threads-context'
|
|
||||||
import { formatTimeBasedOnYear } from '@/features/utils/format-date'
|
import { formatTimeBasedOnYear } from '@/features/utils/format-date'
|
||||||
import Tooltip from '@/shared/components/tooltip'
|
import Tooltip from '@/shared/components/tooltip'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
|
@ -17,49 +16,39 @@ import ReviewPanelDeleteCommentModal from './review-panel-delete-comment-modal'
|
||||||
|
|
||||||
export const ReviewPanelMessage: FC<{
|
export const ReviewPanelMessage: FC<{
|
||||||
message: ReviewPanelCommentThreadMessage
|
message: ReviewPanelCommentThreadMessage
|
||||||
threadId: ThreadId
|
|
||||||
hasReplies: boolean
|
hasReplies: boolean
|
||||||
isReply: boolean
|
isReply: boolean
|
||||||
onResolve: () => void
|
onResolve?: () => Promise<void>
|
||||||
|
onEdit?: (commentId: CommentId, content: string) => Promise<void>
|
||||||
|
onDelete?: (CommentId: CommentId) => Promise<void>
|
||||||
isThreadResolved: boolean
|
isThreadResolved: boolean
|
||||||
}> = ({
|
}> = ({
|
||||||
message,
|
message,
|
||||||
threadId,
|
|
||||||
isReply,
|
isReply,
|
||||||
hasReplies,
|
hasReplies,
|
||||||
onResolve,
|
onResolve,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
isThreadResolved,
|
isThreadResolved,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [error, setError] = useState<Error>()
|
|
||||||
const [content, setContent] = useState(message.content)
|
const [content, setContent] = useState(message.content)
|
||||||
const { editMessage, deleteMessage } = useThreadsActionsContext()
|
|
||||||
|
|
||||||
const handleEditOption = useCallback(() => setEditing(true), [])
|
const handleEditOption = useCallback(() => setEditing(true), [])
|
||||||
const showDeleteModal = useCallback(() => setDeleting(true), [])
|
const showDeleteModal = useCallback(() => setDeleting(true), [])
|
||||||
const hideDeleteModal = useCallback(() => setDeleting(false), [])
|
const hideDeleteModal = useCallback(() => setDeleting(false), [])
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(() => {
|
||||||
await editMessage(threadId, message.id, content)
|
onEdit?.(message.id, content)
|
||||||
.catch(error => {
|
setEditing(false)
|
||||||
setError(error)
|
}, [content, message.id, onEdit])
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setEditing(false)
|
|
||||||
})
|
|
||||||
}, [content, editMessage, message.id, threadId])
|
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
const handleDelete = useCallback(() => {
|
||||||
await deleteMessage(threadId, message.id)
|
onDelete?.(message.id)
|
||||||
.catch(error => {
|
setDeleting(false)
|
||||||
setError(error)
|
}, [message.id, onDelete])
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setDeleting(false)
|
|
||||||
})
|
|
||||||
}, [deleteMessage, message.id, threadId])
|
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
|
@ -83,7 +72,6 @@ export const ReviewPanelMessage: FC<{
|
||||||
value={content}
|
value={content}
|
||||||
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
|
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
|
||||||
/>
|
/>
|
||||||
{error && <div>{error.message}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { FC } from 'react'
|
import React, { FC, useCallback, useState } from 'react'
|
||||||
import { useTranslation, Trans } from 'react-i18next'
|
import { useTranslation, Trans } from 'react-i18next'
|
||||||
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
||||||
import { useThreadsActionsContext } from '../context/threads-context'
|
import { useThreadsActionsContext } from '../context/threads-context'
|
||||||
|
@ -8,17 +8,57 @@ import { ExpandableContent } from './review-panel-expandable-content'
|
||||||
import { ReviewPanelCommentContent } from './review-panel-comment-content'
|
import { ReviewPanelCommentContent } from './review-panel-comment-content'
|
||||||
import { Change, CommentOperation } from '../../../../../types/change'
|
import { Change, CommentOperation } from '../../../../../types/change'
|
||||||
import Tooltip from '@/shared/components/tooltip'
|
import Tooltip from '@/shared/components/tooltip'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import { debugConsole } from '@/utils/debugging'
|
||||||
|
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||||
|
|
||||||
export const ReviewPanelResolvedThread: FC<{
|
export const ReviewPanelResolvedThread: FC<{
|
||||||
id: string
|
id: ThreadId
|
||||||
comment: Change<CommentOperation>
|
comment: Change<CommentOperation>
|
||||||
docName: string
|
docName: string
|
||||||
}> = ({ id, comment, docName }) => {
|
}> = ({ id, comment, docName }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { reopenThread, deleteThread } = useThreadsActionsContext()
|
const { reopenThread, deleteThread } = useThreadsActionsContext()
|
||||||
|
const [processing, setProcessing] = useState(false)
|
||||||
|
const { showGenericMessageModal } = useModalsContext()
|
||||||
|
|
||||||
|
const handleReopenThread = useCallback(async () => {
|
||||||
|
setProcessing(true)
|
||||||
|
try {
|
||||||
|
await reopenThread(id)
|
||||||
|
} catch (err) {
|
||||||
|
debugConsole.error(err)
|
||||||
|
showGenericMessageModal(
|
||||||
|
t('reopen_comment_error_title'),
|
||||||
|
t('reopen_comment_error_message')
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setProcessing(false)
|
||||||
|
}
|
||||||
|
}, [id, reopenThread, showGenericMessageModal, t])
|
||||||
|
|
||||||
|
const handleDeleteThread = useCallback(async () => {
|
||||||
|
setProcessing(true)
|
||||||
|
try {
|
||||||
|
await deleteThread(id)
|
||||||
|
} catch (err) {
|
||||||
|
debugConsole.error(err)
|
||||||
|
showGenericMessageModal(
|
||||||
|
t('delete_comment_error_title'),
|
||||||
|
t('delete_comment_error_message')
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setProcessing(false)
|
||||||
|
}
|
||||||
|
}, [id, deleteThread, showGenericMessageModal, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="review-panel-resolved-comment" key={id}>
|
<div
|
||||||
|
className={classNames('review-panel-resolved-comment', {
|
||||||
|
'review-panel-resolved-disabled': processing,
|
||||||
|
})}
|
||||||
|
key={id}
|
||||||
|
>
|
||||||
<div className="review-panel-resolved-comment-header">
|
<div className="review-panel-resolved-comment-header">
|
||||||
<div>
|
<div>
|
||||||
<Trans
|
<Trans
|
||||||
|
@ -38,7 +78,7 @@ export const ReviewPanelResolvedThread: FC<{
|
||||||
overlayProps={{ placement: 'bottom' }}
|
overlayProps={{ placement: 'bottom' }}
|
||||||
description={t('reopen')}
|
description={t('reopen')}
|
||||||
>
|
>
|
||||||
<Button onClick={() => reopenThread(id as ThreadId)}>
|
<Button onClick={handleReopenThread}>
|
||||||
<MaterialIcon type="refresh" accessibilityLabel={t('reopen')} />
|
<MaterialIcon type="refresh" accessibilityLabel={t('reopen')} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
@ -48,7 +88,7 @@ export const ReviewPanelResolvedThread: FC<{
|
||||||
overlayProps={{ placement: 'bottom' }}
|
overlayProps={{ placement: 'bottom' }}
|
||||||
description={t('delete')}
|
description={t('delete')}
|
||||||
>
|
>
|
||||||
<Button onClick={() => deleteThread(id as ThreadId)}>
|
<Button onClick={handleDeleteThread}>
|
||||||
<MaterialIcon type="delete" accessibilityLabel={t('delete')} />
|
<MaterialIcon type="delete" accessibilityLabel={t('delete')} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import useProjectRanges from '../hooks/use-project-ranges'
|
||||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||||
import Icon from '@/shared/components/icon'
|
import Icon from '@/shared/components/icon'
|
||||||
import { Change, CommentOperation } from '../../../../../types/change'
|
import { Change, CommentOperation } from '../../../../../types/change'
|
||||||
|
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
||||||
|
|
||||||
export const ReviewPanelResolvedThreadsMenu: FC = () => {
|
export const ReviewPanelResolvedThreadsMenu: FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
@ -92,7 +93,7 @@ export const ReviewPanelResolvedThreadsMenu: FC = () => {
|
||||||
return (
|
return (
|
||||||
<ReviewPanelResolvedThread
|
<ReviewPanelResolvedThread
|
||||||
key={thread.id}
|
key={thread.id}
|
||||||
id={thread.id}
|
id={thread.id as ThreadId}
|
||||||
comment={comment}
|
comment={comment}
|
||||||
docName={docNameForThread.get(thread.id) ?? t('unknown')}
|
docName={docNameForThread.get(thread.id) ?? t('unknown')}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -16,6 +16,8 @@ import {
|
||||||
import RangesTracker from '@overleaf/ranges-tracker'
|
import RangesTracker from '@overleaf/ranges-tracker'
|
||||||
import { rejectChanges } from '@/features/source-editor/extensions/changes/reject-changes'
|
import { rejectChanges } from '@/features/source-editor/extensions/changes/reject-changes'
|
||||||
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
|
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
|
||||||
|
import { postJSON } from '@/infrastructure/fetch-json'
|
||||||
|
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
|
||||||
|
|
||||||
export type Ranges = {
|
export type Ranges = {
|
||||||
docId: string
|
docId: string
|
||||||
|
@ -57,7 +59,7 @@ const RangesActionsContext = createContext<RangesActions | undefined>(undefined)
|
||||||
|
|
||||||
export const RangesProvider: FC = ({ children }) => {
|
export const RangesProvider: FC = ({ children }) => {
|
||||||
const view = useCodeMirrorViewContext()
|
const view = useCodeMirrorViewContext()
|
||||||
|
const { projectId } = useIdeReactContext()
|
||||||
const [currentDoc] = useScopeValue<DocumentContainer | null>(
|
const [currentDoc] = useScopeValue<DocumentContainer | null>(
|
||||||
'editor.sharejs_doc'
|
'editor.sharejs_doc'
|
||||||
)
|
)
|
||||||
|
@ -115,8 +117,10 @@ export const RangesProvider: FC = ({ children }) => {
|
||||||
|
|
||||||
const actions = useMemo(
|
const actions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
acceptChanges(...ids: string[]) {
|
async acceptChanges(...ids: string[]) {
|
||||||
if (currentDoc?.ranges) {
|
if (currentDoc?.ranges) {
|
||||||
|
const url = `/project/${projectId}/doc/${currentDoc.doc_id}/changes/accept`
|
||||||
|
await postJSON(url, { body: { change_ids: ids } })
|
||||||
currentDoc.ranges.removeChangeIds(ids)
|
currentDoc.ranges.removeChangeIds(ids)
|
||||||
setRanges(buildRanges(currentDoc))
|
setRanges(buildRanges(currentDoc))
|
||||||
}
|
}
|
||||||
|
@ -127,7 +131,7 @@ export const RangesProvider: FC = ({ children }) => {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[currentDoc, view]
|
[currentDoc, projectId, view]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -248,7 +248,6 @@ export const ThreadsProvider: FC = ({ children }) => {
|
||||||
await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
|
await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
|
||||||
body: { content },
|
body: { content },
|
||||||
})
|
})
|
||||||
// TODO: error_submitting_comment
|
|
||||||
},
|
},
|
||||||
async editMessage(
|
async editMessage(
|
||||||
threadId: ThreadId,
|
threadId: ThreadId,
|
||||||
|
|
|
@ -31,6 +31,11 @@
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-panel-entry.review-panel-entry-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.review-panel-entry-indicator {
|
.review-panel-entry-indicator {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@ -235,6 +240,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-panel-resolved-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.review-panel-resolved-comments-loading {
|
.review-panel-resolved-comments-loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,8 @@
|
||||||
"accept_all": "Accept all",
|
"accept_all": "Accept all",
|
||||||
"accept_and_continue": "Accept and continue",
|
"accept_and_continue": "Accept and continue",
|
||||||
"accept_change": "Accept change",
|
"accept_change": "Accept change",
|
||||||
|
"accept_change_error_description": "There was an error accepting a track change. Please try again in a few moments.",
|
||||||
|
"accept_change_error_title": "Accept Change Error",
|
||||||
"accept_invitation": "Accept invitation",
|
"accept_invitation": "Accept invitation",
|
||||||
"accept_or_reject_each_changes_individually": "Accept or reject each change individually",
|
"accept_or_reject_each_changes_individually": "Accept or reject each change individually",
|
||||||
"accept_terms_and_conditions": "Accept terms and conditions",
|
"accept_terms_and_conditions": "Accept terms and conditions",
|
||||||
|
@ -64,6 +66,8 @@
|
||||||
"add_another_token": "Add another token",
|
"add_another_token": "Add another token",
|
||||||
"add_comma_separated_emails_help": "Separate multiple email addresses using the comma (,) character.",
|
"add_comma_separated_emails_help": "Separate multiple email addresses using the comma (,) character.",
|
||||||
"add_comment": "Add comment",
|
"add_comment": "Add comment",
|
||||||
|
"add_comment_error_message": "There was an error adding your comment. Please try again in a few moments.",
|
||||||
|
"add_comment_error_title": "Add Comment Error",
|
||||||
"add_company_details": "Add Company Details",
|
"add_company_details": "Add Company Details",
|
||||||
"add_email": "Add Email",
|
"add_email": "Add Email",
|
||||||
"add_email_address": "Add email address",
|
"add_email_address": "Add email address",
|
||||||
|
@ -427,6 +431,8 @@
|
||||||
"delete_authentication_token_info": "You’re about to delete a Git authentication token. If you do, it can no longer be used to authenticate your identity when performing Git operations.",
|
"delete_authentication_token_info": "You’re about to delete a Git authentication token. If you do, it can no longer be used to authenticate your identity when performing Git operations.",
|
||||||
"delete_certificate": "Delete certificate",
|
"delete_certificate": "Delete certificate",
|
||||||
"delete_comment": "Delete comment",
|
"delete_comment": "Delete comment",
|
||||||
|
"delete_comment_error_message": "There was an error deleting your comment. Please try again in a few moments.",
|
||||||
|
"delete_comment_error_title": "Delete Comment Error",
|
||||||
"delete_comment_message": "You cannot undo this action.",
|
"delete_comment_message": "You cannot undo this action.",
|
||||||
"delete_comment_thread": "Delete comment thread",
|
"delete_comment_thread": "Delete comment thread",
|
||||||
"delete_comment_thread_message": "This will delete the whole comment thread. You cannot undo this action.",
|
"delete_comment_thread_message": "This will delete the whole comment thread. You cannot undo this action.",
|
||||||
|
@ -526,6 +532,8 @@
|
||||||
"easily_manage_your_project_files_everywhere": "Easily manage your project files, everywhere",
|
"easily_manage_your_project_files_everywhere": "Easily manage your project files, everywhere",
|
||||||
"easy_collaboration_for_students": "Easy collaboration for students. Supports longer or more complex projects.",
|
"easy_collaboration_for_students": "Easy collaboration for students. Supports longer or more complex projects.",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
"edit_comment_error_message": "There was an error editing your comment. Please try again in a few moments.",
|
||||||
|
"edit_comment_error_title": "Edit Comment Error",
|
||||||
"edit_dictionary": "Edit Dictionary",
|
"edit_dictionary": "Edit Dictionary",
|
||||||
"edit_dictionary_empty": "Your custom dictionary is empty.",
|
"edit_dictionary_empty": "Your custom dictionary is empty.",
|
||||||
"edit_dictionary_remove": "Remove from dictionary",
|
"edit_dictionary_remove": "Remove from dictionary",
|
||||||
|
@ -1680,6 +1688,8 @@
|
||||||
"rename_project": "Rename Project",
|
"rename_project": "Rename Project",
|
||||||
"renaming": "Renaming",
|
"renaming": "Renaming",
|
||||||
"reopen": "Re-open",
|
"reopen": "Re-open",
|
||||||
|
"reopen_comment_error_message": "There was an error reopening your comment. Please try again in a few moments.",
|
||||||
|
"reopen_comment_error_title": "Reopen Comment Error",
|
||||||
"replace_figure": "Replace figure",
|
"replace_figure": "Replace figure",
|
||||||
"replace_from_another_project": "Replace from another project",
|
"replace_from_another_project": "Replace from another project",
|
||||||
"replace_from_computer": "Replace from computer",
|
"replace_from_computer": "Replace from computer",
|
||||||
|
|
Loading…
Reference in a new issue