first commit
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
import { forwardRef, memo, MouseEventHandler, useState } from 'react'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
|
||||
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import classNames from 'classnames'
|
||||
import {
|
||||
useTrackChangesStateActionsContext,
|
||||
useTrackChangesStateContext,
|
||||
} from '../context/track-changes-state-context'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import usePersistedState from '@/shared/hooks/use-persisted-state'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import { useEditorContext } from '@/shared/context/editor-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import UpgradeTrackChangesModal from './upgrade-track-changes-modal'
|
||||
|
||||
type Mode = 'view' | 'review' | 'edit'
|
||||
|
||||
const useCurrentMode = (): Mode => {
|
||||
const trackChanges = useTrackChangesStateContext()
|
||||
const user = useUserContext()
|
||||
const trackChangesForCurrentUser =
|
||||
trackChanges?.onForEveryone ||
|
||||
(user && user.id && trackChanges?.onForMembers[user.id])
|
||||
const { permissionsLevel } = useEditorContext()
|
||||
|
||||
if (permissionsLevel === 'readOnly') {
|
||||
return 'view'
|
||||
} else if (permissionsLevel === 'review') {
|
||||
return 'review'
|
||||
} else if (trackChangesForCurrentUser) {
|
||||
return 'review'
|
||||
} else {
|
||||
return 'edit'
|
||||
}
|
||||
}
|
||||
|
||||
function ReviewModeSwitcher() {
|
||||
const { t } = useTranslation()
|
||||
const { saveTrackChangesForCurrentUser } =
|
||||
useTrackChangesStateActionsContext()
|
||||
const mode = useCurrentMode()
|
||||
const { permissionsLevel } = useEditorContext()
|
||||
const { write, trackedWrite } = usePermissionsContext()
|
||||
const project = useProjectContext()
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false)
|
||||
const showViewOption = permissionsLevel === 'readOnly'
|
||||
|
||||
return (
|
||||
<div className="review-mode-switcher-container">
|
||||
<Dropdown className="review-mode-switcher" align="end">
|
||||
<DropdownToggle
|
||||
as={ModeSwitcherToggleButton}
|
||||
id="review-mode-switcher"
|
||||
/>
|
||||
<DropdownMenu flip={false}>
|
||||
<OLDropdownMenuItem
|
||||
disabled={!write}
|
||||
onClick={() => {
|
||||
if (mode === 'edit') {
|
||||
return
|
||||
}
|
||||
sendMB('editing-mode-change', {
|
||||
role: permissionsLevel,
|
||||
previousMode: mode,
|
||||
newMode: 'edit',
|
||||
})
|
||||
saveTrackChangesForCurrentUser(false)
|
||||
}}
|
||||
description={t('can_edit_content')}
|
||||
leadingIcon="edit"
|
||||
active={write && mode === 'edit'}
|
||||
>
|
||||
{t('editing')}
|
||||
</OLDropdownMenuItem>
|
||||
<OLDropdownMenuItem
|
||||
disabled={permissionsLevel === 'readOnly'}
|
||||
onClick={() => {
|
||||
if (mode === 'review') {
|
||||
return
|
||||
}
|
||||
if (!project.features.trackChanges) {
|
||||
setShowUpgradeModal(true)
|
||||
} else {
|
||||
sendMB('editing-mode-change', {
|
||||
role: permissionsLevel,
|
||||
previousMode: mode,
|
||||
newMode: 'review',
|
||||
})
|
||||
saveTrackChangesForCurrentUser(true)
|
||||
}
|
||||
}}
|
||||
description={
|
||||
permissionsLevel === 'review' && !trackedWrite
|
||||
? t('comment_only')
|
||||
: t('edits_become_suggestions')
|
||||
}
|
||||
leadingIcon="rate_review"
|
||||
active={trackedWrite && mode === 'review'}
|
||||
>
|
||||
{t('reviewing')}
|
||||
</OLDropdownMenuItem>
|
||||
{showViewOption && (
|
||||
<OLDropdownMenuItem
|
||||
description={t('can_view_content')}
|
||||
leadingIcon="visibility"
|
||||
active={mode === 'view'}
|
||||
>
|
||||
{t('viewing')}
|
||||
</OLDropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
<UpgradeTrackChangesModal
|
||||
show={showUpgradeModal}
|
||||
setShow={setShowUpgradeModal}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ModeSwitcherToggleButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{ onClick: MouseEventHandler<HTMLButtonElement>; 'aria-expanded': boolean }
|
||||
>(({ onClick, 'aria-expanded': ariaExpanded }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const mode = useCurrentMode()
|
||||
|
||||
if (mode === 'edit') {
|
||||
return (
|
||||
<ModeSwitcherToggleButtonContent
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
className="editing"
|
||||
iconType="edit"
|
||||
label={t('editing')}
|
||||
ariaExpanded={ariaExpanded}
|
||||
/>
|
||||
)
|
||||
} else if (mode === 'review') {
|
||||
return (
|
||||
<ModeSwitcherToggleButtonContent
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
className="reviewing"
|
||||
iconType="rate_review"
|
||||
label={t('reviewing')}
|
||||
ariaExpanded={ariaExpanded}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ModeSwitcherToggleButtonContent
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
className="viewing"
|
||||
iconType="visibility"
|
||||
label={t('viewing')}
|
||||
ariaExpanded={ariaExpanded}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const ModeSwitcherToggleButtonContent = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
onClick: MouseEventHandler<HTMLButtonElement>
|
||||
className: string
|
||||
iconType: string
|
||||
label: string
|
||||
ariaExpanded: boolean
|
||||
}
|
||||
>(({ onClick, className, iconType, label, ariaExpanded }, ref) => {
|
||||
const [isFirstTimeUsed, setIsFirstTimeUsed] = usePersistedState(
|
||||
`modeSwitcherFirstTimeUsed`,
|
||||
true
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames('review-mode-switcher-toggle-button', className, {
|
||||
'review-mode-switcher-toggle-button-expanded': isFirstTimeUsed,
|
||||
})}
|
||||
ref={ref}
|
||||
onClick={event => {
|
||||
setIsFirstTimeUsed(false)
|
||||
onClick(event)
|
||||
}}
|
||||
aria-expanded={ariaExpanded}
|
||||
>
|
||||
<MaterialIcon className="material-symbols-outlined" type={iconType} />
|
||||
<div className="review-mode-switcher-toggle-label" aria-label={label}>
|
||||
{label}
|
||||
</div>
|
||||
<MaterialIcon type="keyboard_arrow_down" />
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
ModeSwitcherToggleButton.displayName = 'ModeSwitcherToggleButton'
|
||||
ModeSwitcherToggleButtonContent.displayName = 'ModeSwitcherToggleButtonContent'
|
||||
|
||||
export default memo(ReviewModeSwitcher)
|
||||
@@ -0,0 +1,173 @@
|
||||
import { FormEventHandler, useCallback, useState, useRef, memo } from 'react'
|
||||
import {
|
||||
useCodeMirrorStateContext,
|
||||
useCodeMirrorViewContext,
|
||||
} from '@/features/source-editor/components/codemirror-context'
|
||||
import { EditorSelection } from '@codemirror/state'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useThreadsActionsContext } from '../context/threads-context'
|
||||
import { removeNewCommentRangeEffect } from '@/features/source-editor/extensions/review-tooltip'
|
||||
import useSubmittableTextInput from '../hooks/use-submittable-text-input'
|
||||
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 '@/features/ui/components/ol/ol-button'
|
||||
|
||||
export const ReviewPanelAddComment = memo<{
|
||||
docId: string
|
||||
from: number
|
||||
to: number
|
||||
threadId: string
|
||||
top: number | undefined
|
||||
}>(function ReviewPanelAddComment({ from, to, threadId, top, docId }) {
|
||||
const { t } = useTranslation()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const state = useCodeMirrorStateContext()
|
||||
const { addComment } = useThreadsActionsContext()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const { showGenericMessageModal } = useModalsContext()
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
view.dispatch({
|
||||
effects: removeNewCommentRangeEffect.of(threadId),
|
||||
})
|
||||
}, [view, threadId])
|
||||
|
||||
const submitForm = useCallback(
|
||||
async message => {
|
||||
setSubmitting(true)
|
||||
|
||||
const content = view.state.sliceDoc(from, to)
|
||||
|
||||
try {
|
||||
await addComment(from, content, message)
|
||||
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)
|
||||
},
|
||||
[addComment, view, handleClose, from, to, showGenericMessageModal, t]
|
||||
)
|
||||
|
||||
const { handleChange, handleKeyPress, content } =
|
||||
useSubmittableTextInput(submitForm)
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (content === '') {
|
||||
window.setTimeout(() => {
|
||||
handleClose()
|
||||
})
|
||||
}
|
||||
}, [content, handleClose])
|
||||
|
||||
const handleSubmit = useCallback<FormEventHandler>(
|
||||
event => {
|
||||
event.preventDefault()
|
||||
submitForm(content)
|
||||
},
|
||||
[submitForm, content]
|
||||
)
|
||||
|
||||
// We only ever want to focus the element once
|
||||
const hasBeenFocused = useRef(false)
|
||||
|
||||
// Auto-focus the textarea 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
|
||||
const observerCallback = useCallback(mutationList => {
|
||||
if (hasBeenFocused.current) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const mutation of mutationList) {
|
||||
if (mutation.target.style.top) {
|
||||
const textArea = mutation.target.getElementsByTagName('textarea')[0]
|
||||
if (textArea) {
|
||||
textArea.focus()
|
||||
hasBeenFocused.current = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleElement = useCallback(
|
||||
(element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
element.dispatchEvent(new Event('review-panel:position'))
|
||||
|
||||
const observer = new MutationObserver(observerCallback)
|
||||
const entryWrapper = element.closest('.review-panel-entry')
|
||||
if (entryWrapper) {
|
||||
observer.observe(entryWrapper, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
})
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
}
|
||||
},
|
||||
[observerCallback]
|
||||
)
|
||||
|
||||
return (
|
||||
<ReviewPanelEntry
|
||||
docId={docId}
|
||||
top={top}
|
||||
position={from}
|
||||
op={{
|
||||
p: from,
|
||||
c: state.sliceDoc(from, to),
|
||||
t: threadId as ThreadId,
|
||||
}}
|
||||
selectLineOnFocus={false}
|
||||
disabled={submitting}
|
||||
>
|
||||
<form
|
||||
className="review-panel-entry-content"
|
||||
onBlur={handleBlur}
|
||||
onSubmit={handleSubmit}
|
||||
ref={handleElement}
|
||||
>
|
||||
<AutoExpandingTextArea
|
||||
name="message"
|
||||
className="review-panel-add-comment-textarea"
|
||||
onChange={handleChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={t('add_your_comment_here')}
|
||||
value={content}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div className="review-panel-add-comment-buttons">
|
||||
<OLButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="review-panel-add-comment-cancel-button"
|
||||
disabled={submitting}
|
||||
onClick={handleClose}
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={content === '' || submitting}
|
||||
>
|
||||
{t('comment')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</form>
|
||||
</ReviewPanelEntry>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { useChangesUsersContext } from '../context/changes-users-context'
|
||||
import { Change } from '../../../../../types/change'
|
||||
import ReviewPanelEntryUser from './review-panel-entry-user'
|
||||
|
||||
export const ReviewPanelChangeUser = memo<{ change: Change }>(({ change }) => {
|
||||
const changesUsers = useChangesUsersContext()
|
||||
const userId = change.metadata?.user_id
|
||||
const user = useMemo(
|
||||
() => (userId ? changesUsers?.get(userId) : undefined),
|
||||
[changesUsers, userId]
|
||||
)
|
||||
|
||||
return <ReviewPanelEntryUser user={user} />
|
||||
})
|
||||
ReviewPanelChangeUser.displayName = 'ReviewPanelChangeUser'
|
||||
@@ -0,0 +1,239 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useRangesActionsContext } from '../context/ranges-context'
|
||||
import {
|
||||
Change,
|
||||
DeleteOperation,
|
||||
EditOperation,
|
||||
} from '../../../../../types/change'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classnames from 'classnames'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year'
|
||||
import { useChangesUsersContext } from '../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'
|
||||
import { ExpandableContent } from './review-panel-expandable-content'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
import { PreventSelectingEntry } from './review-panel-prevent-selecting'
|
||||
|
||||
export const ReviewPanelChange = memo<{
|
||||
change: Change<EditOperation>
|
||||
aggregate?: Change<DeleteOperation>
|
||||
top?: number
|
||||
editable?: boolean
|
||||
docId: string
|
||||
hoverRanges?: boolean
|
||||
hovered?: boolean
|
||||
onEnter?: (changeId: string) => void
|
||||
onLeave?: (changeId: string) => void
|
||||
}>(
|
||||
({
|
||||
change,
|
||||
aggregate,
|
||||
top,
|
||||
docId,
|
||||
hoverRanges,
|
||||
editable = true,
|
||||
hovered,
|
||||
onEnter,
|
||||
onLeave,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { acceptChanges, rejectChanges } = useRangesActionsContext()
|
||||
const permissions = usePermissionsContext()
|
||||
const changesUsers = useChangesUsersContext()
|
||||
const { showGenericMessageModal } = useModalsContext()
|
||||
const user = useUserContext()
|
||||
|
||||
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 users are not loaded yet, do not show "Unknown" user
|
||||
return null
|
||||
}
|
||||
|
||||
const isChangeAuthor = change.metadata?.user_id === user.id
|
||||
const aggregateChange = aggregate && /\S/.test(aggregate.op.d)
|
||||
|
||||
return (
|
||||
<ReviewPanelEntry
|
||||
className={classnames('review-panel-entry-change', {
|
||||
'review-panel-entry-insert': 'i' in change.op,
|
||||
'review-panel-entry-delete': 'd' in change.op,
|
||||
'review-panel-entry-hover': hovered,
|
||||
// TODO: aggregate
|
||||
})}
|
||||
top={top}
|
||||
op={change.op}
|
||||
position={change.op.p}
|
||||
docId={docId}
|
||||
hoverRanges={hoverRanges}
|
||||
disabled={accepting}
|
||||
onEnterEntryIndicator={onEnter && (() => onEnter(change.id))}
|
||||
onLeaveEntryIndicator={onLeave && (() => onLeave(change.id))}
|
||||
entryIndicator="edit"
|
||||
>
|
||||
<div
|
||||
className="review-panel-entry-content"
|
||||
onMouseEnter={onEnter && (() => onEnter(change.id))}
|
||||
onMouseLeave={onLeave && (() => onLeave(change.id))}
|
||||
>
|
||||
<div className="review-panel-entry-header">
|
||||
<div>
|
||||
<ReviewPanelChangeUser change={change} />
|
||||
{change.metadata?.ts && (
|
||||
<div className="review-panel-entry-time">
|
||||
<FormatTimeBasedOnYear date={change.metadata.ts} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{editable && (
|
||||
<div className="review-panel-entry-actions">
|
||||
{permissions.write && (
|
||||
<PreventSelectingEntry>
|
||||
<OLTooltip
|
||||
id="accept-change"
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
description={t('accept_change')}
|
||||
tooltipProps={{ className: 'review-panel-tooltip' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={acceptHandler}
|
||||
tabIndex={0}
|
||||
>
|
||||
<MaterialIcon
|
||||
type="check"
|
||||
className="review-panel-entry-actions-icon"
|
||||
accessibilityLabel={t('accept_change')}
|
||||
/>
|
||||
</button>
|
||||
</OLTooltip>
|
||||
</PreventSelectingEntry>
|
||||
)}
|
||||
|
||||
{(permissions.write ||
|
||||
(permissions.trackedWrite && isChangeAuthor)) && (
|
||||
<PreventSelectingEntry>
|
||||
<OLTooltip
|
||||
id="reject-change"
|
||||
description={t('reject_change')}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
tooltipProps={{ className: 'review-panel-tooltip' }}
|
||||
>
|
||||
<button
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
aggregate
|
||||
? rejectChanges(change.id, aggregate.id)
|
||||
: rejectChanges(change.id)
|
||||
}
|
||||
>
|
||||
<MaterialIcon
|
||||
className="review-panel-entry-actions-icon"
|
||||
accessibilityLabel={t('reject_change')}
|
||||
type="close"
|
||||
/>
|
||||
</button>
|
||||
</OLTooltip>
|
||||
</PreventSelectingEntry>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="review-panel-change-body">
|
||||
{'i' in change.op && (
|
||||
<>
|
||||
{aggregateChange ? (
|
||||
<MaterialIcon
|
||||
className="review-panel-entry-icon review-panel-entry-change-icon review-panel-entry-icon-changed"
|
||||
type="edit"
|
||||
/>
|
||||
) : (
|
||||
<MaterialIcon
|
||||
className="review-panel-entry-icon review-panel-entry-change-icon review-panel-entry-icon-accept"
|
||||
type="add_circle"
|
||||
/>
|
||||
)}
|
||||
|
||||
{aggregateChange ? (
|
||||
<span>
|
||||
{t('aggregate_changed')}:{' '}
|
||||
<del className="review-panel-content-highlight">
|
||||
<ExpandableContent
|
||||
inline
|
||||
content={aggregate.op.d}
|
||||
checkNewLines={false}
|
||||
/>
|
||||
</del>{' '}
|
||||
{t('aggregate_to')}{' '}
|
||||
<ExpandableContent
|
||||
inline
|
||||
content={change.op.i}
|
||||
checkNewLines={false}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{t('tracked_change_added')}:
|
||||
<ins className="review-panel-content-highlight">
|
||||
<ExpandableContent
|
||||
content={change.op.i}
|
||||
checkNewLines={false}
|
||||
/>
|
||||
</ins>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{'d' in change.op && (
|
||||
<>
|
||||
<MaterialIcon
|
||||
className="review-panel-entry-icon review-panel-entry-change-icon review-panel-entry-icon-reject"
|
||||
type="delete"
|
||||
/>
|
||||
|
||||
<span>
|
||||
{t('tracked_change_deleted')}:
|
||||
<del className="review-panel-content-highlight">
|
||||
<ExpandableContent
|
||||
content={change.op.d}
|
||||
checkNewLines={false}
|
||||
/>
|
||||
</del>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ReviewPanelEntry>
|
||||
)
|
||||
}
|
||||
)
|
||||
ReviewPanelChange.displayName = 'ReviewPanelChange'
|
||||
@@ -0,0 +1,123 @@
|
||||
import { memo, useCallback, 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 ReviewPanelResolvedMessage from './review-panel-resolved-message'
|
||||
import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread'
|
||||
import useSubmittableTextInput from '../hooks/use-submittable-text-input'
|
||||
import {
|
||||
CommentId,
|
||||
ThreadId,
|
||||
} from '../../../../../types/review-panel/review-panel'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
|
||||
export const ReviewPanelCommentContent = memo<{
|
||||
comment: Change<CommentOperation>
|
||||
isResolved: boolean
|
||||
onEdit?: (commentId: CommentId, content: string) => Promise<void>
|
||||
onReply?: (content: string) => Promise<void>
|
||||
onDeleteMessage?: (commentId: CommentId) => Promise<void>
|
||||
onDeleteThread?: (threadId: ThreadId) => Promise<void>
|
||||
onResolve?: () => Promise<void>
|
||||
onLeave?: (changeId: string) => void
|
||||
onEnter?: (changeId: string) => void
|
||||
}>(
|
||||
({
|
||||
comment,
|
||||
isResolved,
|
||||
onResolve,
|
||||
onDeleteMessage,
|
||||
onDeleteThread,
|
||||
onEdit,
|
||||
onReply,
|
||||
onLeave,
|
||||
onEnter,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const threads = useThreadsContext()
|
||||
const permissions = usePermissionsContext()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(content, setContent) => {
|
||||
if (!onReply || submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
onReply(content)
|
||||
.then(() => {
|
||||
setContent('')
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false)
|
||||
})
|
||||
},
|
||||
[onReply, submitting]
|
||||
)
|
||||
|
||||
const { handleChange, handleKeyPress, content } =
|
||||
useSubmittableTextInput(handleSubmit)
|
||||
|
||||
const thread = threads?.[comment.op.t]
|
||||
if (!thread) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="review-panel-entry-content"
|
||||
onMouseEnter={onEnter && (() => onEnter(comment.id))}
|
||||
onMouseLeave={onLeave && (() => onLeave(comment.id))}
|
||||
>
|
||||
{thread.messages.map((message, i) => {
|
||||
const isReply = i !== 0
|
||||
|
||||
return (
|
||||
<div key={message.id} className="review-panel-comment-wrapper">
|
||||
{isReply && (
|
||||
<div className="review-panel-comment-reply-divider" />
|
||||
)}
|
||||
<ReviewPanelMessage
|
||||
message={message}
|
||||
isReply={isReply}
|
||||
hasReplies={!isReply && thread.messages.length > 1}
|
||||
onResolve={onResolve}
|
||||
onEdit={onEdit}
|
||||
onDelete={() =>
|
||||
isReply
|
||||
? onDeleteMessage?.(message.id)
|
||||
: onDeleteThread?.(comment.op.t)
|
||||
}
|
||||
isThreadResolved={isResolved}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{isResolved && (
|
||||
<div className="review-panel-comment-wrapper">
|
||||
<div className="review-panel-comment-reply-divider" />
|
||||
<ReviewPanelResolvedMessage
|
||||
thread={thread as ReviewPanelResolvedCommentThread}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{permissions.comment && !isResolved && (
|
||||
<AutoExpandingTextArea
|
||||
name="content"
|
||||
className="review-panel-comment-input"
|
||||
onChange={handleChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={t('reply')}
|
||||
value={content}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ReviewPanelCommentContent.displayName = 'ReviewPanelCommentContent'
|
||||
@@ -0,0 +1,67 @@
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { FC, memo, forwardRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
|
||||
import classnames from 'classnames'
|
||||
|
||||
const ReviewPanelCommentDropdownToggleButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
>((props, ref) => (
|
||||
<button {...props} ref={ref} className={classnames(props.className, 'btn')} />
|
||||
))
|
||||
ReviewPanelCommentDropdownToggleButton.displayName =
|
||||
'ReviewPanelCommentDropdownToggleButton'
|
||||
|
||||
const ReviewPanelCommentOptions: FC<{
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
id: string
|
||||
canEdit: boolean
|
||||
canDelete: boolean
|
||||
}> = ({ onEdit, onDelete, id, canEdit, canDelete }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!canEdit && !canDelete) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown align="end">
|
||||
<DropdownToggle
|
||||
tabIndex={0}
|
||||
as={ReviewPanelCommentDropdownToggleButton}
|
||||
id={`review-panel-comment-options-btn-${id}`}
|
||||
>
|
||||
<MaterialIcon
|
||||
type="more_vert"
|
||||
className="review-panel-entry-actions-icon"
|
||||
accessibilityLabel={t('more_options')}
|
||||
/>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu flip={false}>
|
||||
{canEdit && (
|
||||
<li role="none">
|
||||
<DropdownItem as="button" onClick={onEdit}>
|
||||
{t('edit')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
{canDelete && (
|
||||
<li role="none">
|
||||
<DropdownItem as="button" onClick={onDelete}>
|
||||
{t('delete')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ReviewPanelCommentOptions)
|
||||
@@ -0,0 +1,180 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { Change, CommentOperation } from '../../../../../types/change'
|
||||
import {
|
||||
useThreadsActionsContext,
|
||||
useThreadsContext,
|
||||
} from '../context/threads-context'
|
||||
import classnames from 'classnames'
|
||||
import { ReviewPanelEntry } from './review-panel-entry'
|
||||
import { ReviewPanelCommentContent } from './review-panel-comment-content'
|
||||
import {
|
||||
CommentId,
|
||||
ThreadId,
|
||||
} from '../../../../../types/review-panel/review-panel'
|
||||
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
export const ReviewPanelComment = memo<{
|
||||
comment: Change<CommentOperation>
|
||||
docId: string
|
||||
top?: number
|
||||
hoverRanges?: boolean
|
||||
onEnter?: (changeId: string) => void
|
||||
onLeave?: (changeId: string) => void
|
||||
hovered?: boolean
|
||||
}>(({ comment, top, hovered, onEnter, onLeave, docId, hoverRanges }) => {
|
||||
const threads = useThreadsContext()
|
||||
const {
|
||||
resolveThread,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
deleteOwnMessage,
|
||||
deleteThread,
|
||||
addMessage,
|
||||
} = useThreadsActionsContext()
|
||||
const { showGenericMessageModal } = useModalsContext()
|
||||
const { t } = useTranslation()
|
||||
const permissions = usePermissionsContext()
|
||||
|
||||
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 {
|
||||
if (permissions.resolveAllComments) {
|
||||
// Owners and editors can delete any message
|
||||
await deleteMessage(comment.op.t, commentId)
|
||||
} else if (permissions.resolveOwnComments) {
|
||||
// Reviewers can only delete their own messages
|
||||
await deleteOwnMessage(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,
|
||||
deleteOwnMessage,
|
||||
showGenericMessageModal,
|
||||
t,
|
||||
permissions.resolveOwnComments,
|
||||
permissions.resolveAllComments,
|
||||
]
|
||||
)
|
||||
|
||||
const handleDeleteThread = useCallback(
|
||||
async (commentId: ThreadId) => {
|
||||
setProcessing(true)
|
||||
try {
|
||||
await deleteThread(commentId)
|
||||
} catch (err) {
|
||||
debugConsole.error(err)
|
||||
showGenericMessageModal(
|
||||
t('delete_comment_error_title'),
|
||||
t('delete_comment_error_message')
|
||||
)
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
},
|
||||
[deleteThread, 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]
|
||||
if (!thread || thread.resolved || thread.messages.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ReviewPanelEntry
|
||||
className={classnames('review-panel-entry-comment', {
|
||||
'review-panel-entry-loaded': !!threads?.[comment.op.t],
|
||||
'review-panel-entry-hover': hovered,
|
||||
})}
|
||||
docId={docId}
|
||||
top={top}
|
||||
op={comment.op}
|
||||
position={comment.op.p}
|
||||
hoverRanges={hoverRanges}
|
||||
disabled={processing}
|
||||
onEnterEntryIndicator={onEnter && (() => onEnter(comment.id))}
|
||||
onLeaveEntryIndicator={onLeave && (() => onLeave(comment.id))}
|
||||
entryIndicator="comment"
|
||||
>
|
||||
<ReviewPanelCommentContent
|
||||
comment={comment}
|
||||
isResolved={false}
|
||||
onLeave={onLeave}
|
||||
onEnter={onEnter}
|
||||
onResolve={handleResolveComment}
|
||||
onEdit={handleEditMessage}
|
||||
onDeleteMessage={handleDeleteMessage}
|
||||
onDeleteThread={handleDeleteThread}
|
||||
onReply={handleSubmitReply}
|
||||
/>
|
||||
</ReviewPanelEntry>
|
||||
)
|
||||
})
|
||||
ReviewPanelComment.displayName = 'ReviewPanelComment'
|
||||
@@ -0,0 +1,33 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useCodeMirrorViewContext } from '../../source-editor/components/codemirror-context'
|
||||
import { memo } from 'react'
|
||||
import ReviewPanel from './review-panel'
|
||||
import TrackChangesOnWidget from './track-changes-on-widget'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
import ReviewModeSwitcher from './review-mode-switcher'
|
||||
import getMeta from '@/utils/meta'
|
||||
import useReviewPanelLayout from '../hooks/use-review-panel-layout'
|
||||
|
||||
function ReviewPanelContainer() {
|
||||
const view = useCodeMirrorViewContext()
|
||||
const { showPanel, mini } = useReviewPanelLayout()
|
||||
const { wantTrackChanges } = useEditorManagerContext()
|
||||
const enableReviewerRole = getMeta('ol-isReviewerRoleEnabled')
|
||||
|
||||
if (!view) {
|
||||
return null
|
||||
}
|
||||
|
||||
const showTrackChangesWidget = !enableReviewerRole && wantTrackChanges && mini
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
{showTrackChangesWidget && <TrackChangesOnWidget />}
|
||||
{enableReviewerRole && <ReviewModeSwitcher />}
|
||||
{showPanel && <ReviewPanel mini={mini} />}
|
||||
</>,
|
||||
view.scrollDOM
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ReviewPanelContainer)
|
||||
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
FC,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { ReviewPanelAddComment } from './review-panel-add-comment'
|
||||
import { ReviewPanelChange } from './review-panel-change'
|
||||
import { ReviewPanelComment } from './review-panel-comment'
|
||||
import {
|
||||
Change,
|
||||
CommentOperation,
|
||||
DeleteOperation,
|
||||
EditOperation,
|
||||
} from '../../../../../types/change'
|
||||
import {
|
||||
useCodeMirrorStateContext,
|
||||
useCodeMirrorViewContext,
|
||||
} from '@/features/source-editor/components/codemirror-context'
|
||||
import { useRangesContext } from '../context/ranges-context'
|
||||
import { useThreadsContext } from '../context/threads-context'
|
||||
import { isDeleteChange, isInsertChange } from '@/utils/operations'
|
||||
import { positionItems } from '../utils/position-items'
|
||||
import { canAggregate } from '../utils/can-aggregate'
|
||||
import ReviewPanelEmptyState from './review-panel-empty-state'
|
||||
import useEventListener from '@/shared/hooks/use-event-listener'
|
||||
import { hasActiveRange } from '@/features/review-panel-new/utils/has-active-range'
|
||||
import { reviewTooltipStateField } from '@/features/source-editor/extensions/review-tooltip'
|
||||
import ReviewPanelMoreCommentsButton from './review-panel-more-comments-button'
|
||||
import useMoreCommments from '../hooks/use-more-comments'
|
||||
import { Decoration } from '@codemirror/view'
|
||||
import { debounce } from 'lodash'
|
||||
|
||||
type AggregatedRanges = {
|
||||
changes: Change<EditOperation>[]
|
||||
comments: Change<CommentOperation>[]
|
||||
aggregates: Map<string, Change<DeleteOperation>>
|
||||
}
|
||||
|
||||
const ReviewPanelCurrentFile: FC = () => {
|
||||
const view = useCodeMirrorViewContext()
|
||||
const ranges = useRangesContext()
|
||||
const threads = useThreadsContext()
|
||||
const state = useCodeMirrorStateContext()
|
||||
const [hoveredEntry, setHoveredEntry] = useState<string | null>(null)
|
||||
|
||||
const hoverTimeout = useRef<number>(0)
|
||||
const handleEntryEnter = useCallback((id: string) => {
|
||||
clearTimeout(hoverTimeout.current)
|
||||
setHoveredEntry(id)
|
||||
}, [])
|
||||
|
||||
const handleEntryLeave = useCallback((id: string) => {
|
||||
clearTimeout(hoverTimeout.current)
|
||||
hoverTimeout.current = window.setTimeout(() => {
|
||||
setHoveredEntry(null)
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
const [aggregatedRanges, setAggregatedRanges] = useState<AggregatedRanges>()
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const previousFocusedItem = useRef(new Map<string, number>())
|
||||
|
||||
const buildAggregatedRanges = useCallback(() => {
|
||||
if (ranges) {
|
||||
const output: AggregatedRanges = {
|
||||
aggregates: new Map(),
|
||||
changes: [],
|
||||
comments: [],
|
||||
}
|
||||
|
||||
let precedingChange: Change<EditOperation> | null = null
|
||||
|
||||
for (const change of ranges.changes) {
|
||||
if (
|
||||
precedingChange &&
|
||||
isInsertChange(precedingChange) &&
|
||||
isDeleteChange(change) &&
|
||||
canAggregate(change, precedingChange)
|
||||
) {
|
||||
output.aggregates.set(precedingChange.id, change)
|
||||
} else {
|
||||
output.changes.push(change)
|
||||
}
|
||||
|
||||
precedingChange = change
|
||||
}
|
||||
|
||||
if (threads) {
|
||||
for (const comment of ranges.comments) {
|
||||
if (threads[comment.op.t] && !threads[comment.op.t]?.resolved) {
|
||||
output.comments.push(comment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAggregatedRanges(output)
|
||||
}
|
||||
}, [threads, ranges])
|
||||
|
||||
useEffect(() => {
|
||||
buildAggregatedRanges()
|
||||
}, [buildAggregatedRanges])
|
||||
|
||||
useEventListener('editor:viewport-changed', buildAggregatedRanges)
|
||||
|
||||
const [positions, setPositions] = useState<Map<string, number>>(new Map())
|
||||
|
||||
const positionsRef = useRef<Map<string, number>>(new Map())
|
||||
|
||||
const addCommentRanges = state.field(
|
||||
reviewTooltipStateField,
|
||||
false
|
||||
)?.addCommentRanges
|
||||
|
||||
const setUpdatedPositions = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
setPositions(new Map(positionsRef.current))
|
||||
window.setTimeout(() => {
|
||||
containerRef.current?.dispatchEvent(
|
||||
new Event('review-panel:position')
|
||||
)
|
||||
})
|
||||
}, 50),
|
||||
[]
|
||||
)
|
||||
|
||||
const positionsMeasureRequest = useCallback(() => {
|
||||
if (aggregatedRanges) {
|
||||
view.requestMeasure({
|
||||
key: 'review-panel-position',
|
||||
read(view) {
|
||||
const contentRect = view.contentDOM.getBoundingClientRect()
|
||||
const docLength = view.state.doc.length
|
||||
|
||||
const screenPosition = (position: number): number | undefined => {
|
||||
const pos = Math.min(position, docLength) // TODO: needed?
|
||||
const coords = view.coordsAtPos(pos)
|
||||
|
||||
return coords ? Math.round(coords.top - contentRect.top) : undefined
|
||||
}
|
||||
|
||||
for (const change of aggregatedRanges.changes) {
|
||||
const position = screenPosition(change.op.p)
|
||||
if (position) {
|
||||
positionsRef.current.set(change.id, position)
|
||||
}
|
||||
}
|
||||
|
||||
for (const comment of aggregatedRanges.comments) {
|
||||
const position = screenPosition(comment.op.p)
|
||||
if (position) {
|
||||
positionsRef.current.set(comment.id, position)
|
||||
}
|
||||
}
|
||||
|
||||
if (!addCommentRanges) {
|
||||
return
|
||||
}
|
||||
|
||||
const cursor = addCommentRanges.iter()
|
||||
|
||||
while (cursor.value) {
|
||||
const { from } = cursor
|
||||
const position = screenPosition(from)
|
||||
|
||||
if (position) {
|
||||
positionsRef.current.set(
|
||||
`new-comment-${cursor.value.spec.id}`,
|
||||
position
|
||||
)
|
||||
}
|
||||
|
||||
cursor.next()
|
||||
}
|
||||
},
|
||||
write() {
|
||||
setUpdatedPositions()
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [view, aggregatedRanges, addCommentRanges, setUpdatedPositions])
|
||||
|
||||
useEffect(positionsMeasureRequest, [positionsMeasureRequest])
|
||||
useEventListener('editor:geometry-change', positionsMeasureRequest)
|
||||
|
||||
const showEmptyState = useMemo(
|
||||
() => hasActiveRange(ranges, threads) === false,
|
||||
[ranges, threads]
|
||||
)
|
||||
|
||||
const addCommentEntries = useMemo(() => {
|
||||
if (!addCommentRanges) {
|
||||
return []
|
||||
}
|
||||
|
||||
const cursor = addCommentRanges.iter()
|
||||
|
||||
const entries = []
|
||||
|
||||
while (cursor.value) {
|
||||
const id = `new-comment-${cursor.value.spec.id}`
|
||||
if (!positions.has(id)) {
|
||||
cursor.next()
|
||||
continue
|
||||
}
|
||||
|
||||
const { from, to, value } = cursor
|
||||
|
||||
entries.push({
|
||||
id,
|
||||
from,
|
||||
to,
|
||||
threadId: value.spec.id,
|
||||
top: positions.get(id),
|
||||
})
|
||||
|
||||
cursor.next()
|
||||
}
|
||||
return entries
|
||||
}, [addCommentRanges, positions])
|
||||
|
||||
const {
|
||||
onEntriesPositioned,
|
||||
onMoreCommentsAboveClick,
|
||||
onMoreCommentsBelowClick,
|
||||
} = useMoreCommments(
|
||||
aggregatedRanges?.changes ?? [],
|
||||
aggregatedRanges?.comments ?? [],
|
||||
addCommentRanges ?? Decoration.none
|
||||
)
|
||||
|
||||
const updatePositions = useCallback(() => {
|
||||
const docId = ranges?.docId
|
||||
|
||||
if (containerRef.current && docId) {
|
||||
const positioningRes = positionItems(
|
||||
containerRef.current,
|
||||
previousFocusedItem.current.get(docId),
|
||||
docId
|
||||
)
|
||||
|
||||
onEntriesPositioned()
|
||||
|
||||
if (positioningRes) {
|
||||
previousFocusedItem.current.set(
|
||||
positioningRes.docId,
|
||||
positioningRes.activeItemIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [ranges?.docId, onEntriesPositioned])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
updatePositions()
|
||||
}, 50)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [state, updatePositions])
|
||||
|
||||
const handleContainer = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
containerRef.current = element
|
||||
if (containerRef.current) {
|
||||
containerRef.current.addEventListener(
|
||||
'review-panel:position',
|
||||
updatePositions
|
||||
)
|
||||
}
|
||||
},
|
||||
[updatePositions]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.removeEventListener(
|
||||
'review-panel:position',
|
||||
updatePositions
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [updatePositions])
|
||||
|
||||
if (!aggregatedRanges) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showEmptyState && <ReviewPanelEmptyState />}
|
||||
{onMoreCommentsAboveClick && (
|
||||
<ReviewPanelMoreCommentsButton
|
||||
onClick={onMoreCommentsAboveClick}
|
||||
direction="upward"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div ref={handleContainer}>
|
||||
{addCommentEntries.map(entry => {
|
||||
const { id, from, to, threadId, top } = entry
|
||||
return (
|
||||
<ReviewPanelAddComment
|
||||
docId={ranges!.docId}
|
||||
key={id}
|
||||
from={from}
|
||||
to={to}
|
||||
threadId={threadId}
|
||||
top={top}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{aggregatedRanges.changes.map(
|
||||
change =>
|
||||
positions.has(change.id) && (
|
||||
<ReviewPanelChange
|
||||
docId={ranges!.docId}
|
||||
key={change.id}
|
||||
change={change}
|
||||
top={positions.get(change.id)}
|
||||
aggregate={aggregatedRanges.aggregates.get(change.id)}
|
||||
hovered={hoveredEntry === change.id}
|
||||
onEnter={handleEntryEnter}
|
||||
onLeave={handleEntryLeave}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{aggregatedRanges.comments.map(
|
||||
comment =>
|
||||
positions.has(comment.id) && (
|
||||
<ReviewPanelComment
|
||||
docId={ranges!.docId}
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
top={positions.get(comment.id)}
|
||||
hovered={hoveredEntry === comment.id}
|
||||
onEnter={handleEntryEnter}
|
||||
onLeave={handleEntryLeave}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{onMoreCommentsBelowClick && (
|
||||
<ReviewPanelMoreCommentsButton
|
||||
onClick={onMoreCommentsBelowClick}
|
||||
direction="downward"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ReviewPanelCurrentFile)
|
||||
@@ -0,0 +1,37 @@
|
||||
import { FC, memo } from 'react'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
|
||||
const ReviewPanelDeleteCommentModal: FC<{
|
||||
onHide: () => void
|
||||
onDelete: () => void
|
||||
title: string
|
||||
message: string
|
||||
}> = ({ onHide, onDelete, title, message }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<OLModal show onHide={onHide}>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{title}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
<OLModalBody>{message}</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={onHide}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton variant="danger" onClick={onDelete}>
|
||||
{t('delete')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ReviewPanelDeleteCommentModal)
|
||||
@@ -0,0 +1,22 @@
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function ReviewPanelEmptyState() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="review-panel-empty-state">
|
||||
<div className="review-panel-empty-state-inner">
|
||||
<div className="review-panel-empty-state-comment-icon">
|
||||
<MaterialIcon type="question_answer" />
|
||||
</div>
|
||||
<p>
|
||||
<strong>{t('no_comments_or_suggestions')}</strong>
|
||||
</p>
|
||||
<p>{t('no_one_has_commented_or_left_any_suggestions_yet')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReviewPanelEmptyState
|
||||
@@ -0,0 +1,27 @@
|
||||
import { memo } from 'react'
|
||||
import { buildName } from '../utils/build-name'
|
||||
import { ReviewPanelUser } from '../../../../../types/review-panel/review-panel'
|
||||
import { ChangesUser } from '../context/changes-users-context'
|
||||
import { getBackgroundColorForUserId } from '@/shared/utils/colors'
|
||||
|
||||
const ReviewPanelEntryUser = ({
|
||||
user,
|
||||
}: {
|
||||
user?: ReviewPanelUser | ChangesUser
|
||||
}) => {
|
||||
const userName = buildName(user)
|
||||
|
||||
return (
|
||||
<div className="review-panel-entry-user">
|
||||
<span
|
||||
className="review-panel-entry-user-color-badge"
|
||||
style={{
|
||||
backgroundColor: getBackgroundColorForUserId(user?.id),
|
||||
}}
|
||||
/>
|
||||
{userName}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ReviewPanelEntryUser)
|
||||
@@ -0,0 +1,216 @@
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AnyOperation } from '../../../../../types/change'
|
||||
import {
|
||||
useCodeMirrorStateContext,
|
||||
useCodeMirrorViewContext,
|
||||
} from '@/features/source-editor/components/codemirror-context'
|
||||
import { isSelectionWithinOp } from '../utils/is-selection-within-op'
|
||||
import classNames from 'classnames'
|
||||
import {
|
||||
clearHighlightRanges,
|
||||
highlightRanges,
|
||||
} from '@/features/source-editor/extensions/ranges'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import { EditorSelection } from '@codemirror/state'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { OFFSET_FOR_ENTRIES_ABOVE } from '../utils/position-items'
|
||||
|
||||
export const ReviewPanelEntry: FC<{
|
||||
position: number
|
||||
op: AnyOperation
|
||||
docId: string
|
||||
top?: number
|
||||
className?: string
|
||||
selectLineOnFocus?: boolean
|
||||
hoverRanges?: boolean
|
||||
disabled?: boolean
|
||||
onEnterEntryIndicator?: () => void
|
||||
onLeaveEntryIndicator?: () => void
|
||||
entryIndicator?: 'comment' | 'edit'
|
||||
}> = ({
|
||||
children,
|
||||
position,
|
||||
top,
|
||||
op,
|
||||
className,
|
||||
selectLineOnFocus = true,
|
||||
docId,
|
||||
hoverRanges = true,
|
||||
disabled,
|
||||
onEnterEntryIndicator,
|
||||
onLeaveEntryIndicator,
|
||||
entryIndicator,
|
||||
}) => {
|
||||
const state = useCodeMirrorStateContext()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const { openDocWithId, getCurrentDocumentId } = useEditorManagerContext()
|
||||
const [selected, setSelected] = useState(false)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [textareaFocused, setTextareaFocused] = useState(false)
|
||||
const { setReviewPanelOpen } = useLayoutContext()
|
||||
const highlighted = isSelectionWithinOp(op, state.selection.main)
|
||||
const entryRef = useRef<HTMLDivElement>(null)
|
||||
const mousePressedRef = useRef(false)
|
||||
|
||||
const openReviewPanel = useCallback(() => {
|
||||
setReviewPanelOpen(true)
|
||||
}, [setReviewPanelOpen])
|
||||
|
||||
const selectEntry = useCallback(
|
||||
event => {
|
||||
setFocused(true)
|
||||
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
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
|
||||
// so that it doesn't scroll out of view as user types
|
||||
setTextareaFocused(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (mousePressedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setSelected(true)
|
||||
|
||||
if (!selectLineOnFocus) {
|
||||
return
|
||||
}
|
||||
|
||||
if (getCurrentDocumentId() !== docId) {
|
||||
openDocWithId(docId, { gotoOffset: position, keepCurrentView: true })
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
view.dispatch({
|
||||
selection: EditorSelection.cursor(position),
|
||||
})
|
||||
|
||||
// scroll to line (centered)
|
||||
const blockInfo = view.lineBlockAt(position)
|
||||
const coordsAtPos = view.coordsAtPos(position)
|
||||
const coordsAtLineStart = view.coordsAtPos(blockInfo.from)
|
||||
let wrappedLineOffset = 0
|
||||
if (coordsAtPos !== null && coordsAtLineStart !== null) {
|
||||
wrappedLineOffset = coordsAtPos.top - coordsAtLineStart.top
|
||||
}
|
||||
|
||||
const editorHeight = view.scrollDOM.getBoundingClientRect().height
|
||||
view.scrollDOM.scrollTo({
|
||||
top:
|
||||
blockInfo.top -
|
||||
editorHeight / 2 +
|
||||
view.defaultLineHeight +
|
||||
wrappedLineOffset,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
[
|
||||
getCurrentDocumentId,
|
||||
docId,
|
||||
selectLineOnFocus,
|
||||
view,
|
||||
position,
|
||||
openDocWithId,
|
||||
]
|
||||
)
|
||||
|
||||
// Clear op highlight on dismount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverRanges) {
|
||||
setTimeout(() => {
|
||||
view.dispatch(clearHighlightRanges(op))
|
||||
})
|
||||
}
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={entryRef}
|
||||
onMouseDown={() => {
|
||||
mousePressedRef.current = true
|
||||
}}
|
||||
onMouseUp={event => {
|
||||
mousePressedRef.current = false
|
||||
const isTextSelected = Boolean(window.getSelection()?.toString())
|
||||
if (!isTextSelected && !selected) {
|
||||
selectEntry(event)
|
||||
}
|
||||
}}
|
||||
onFocus={selectEntry}
|
||||
onBlur={() => {
|
||||
mousePressedRef.current = false
|
||||
setSelected(false)
|
||||
setFocused(false)
|
||||
setTextareaFocused(false)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (hoverRanges) {
|
||||
view.dispatch(highlightRanges(op))
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (hoverRanges) {
|
||||
view.dispatch(clearHighlightRanges(op))
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={position + 1}
|
||||
className={classNames(
|
||||
'review-panel-entry',
|
||||
{
|
||||
// 'selected' is used to manually select an entry
|
||||
// useful if the range is within range and you want to show the one outside the viewport
|
||||
// it is not enough to just check isSelectionWithinOp for that
|
||||
'review-panel-entry-selected': selected,
|
||||
// 'focused' is set even when an entry was clicked but not selected (like clicking on a menu option)
|
||||
// used to set z-index above other entries (since entries are not ordered the same way visually and in the DOM)
|
||||
'review-panel-entry-focused': focused,
|
||||
// 'highlighted' is set if the selection is within op but that doesn't necessarily mean it should be selected
|
||||
// multiple entries can be highlighted at the same time
|
||||
'review-panel-entry-highlighted': highlighted,
|
||||
// 'textarea-focused' only changes entry styling (border, shadow etc)
|
||||
// it doesnt change selected entry because that moves the cursor
|
||||
// and repositions entries which can cause textarea to be scrolled out of view
|
||||
'review-panel-entry-textarea-focused': textareaFocused,
|
||||
'review-panel-entry-disabled': disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
data-top={top}
|
||||
data-pos={position}
|
||||
style={{
|
||||
position: top === undefined ? 'relative' : 'absolute',
|
||||
visibility: top === undefined ? 'visible' : 'hidden',
|
||||
transition: 'top .3s, left .1s, right .1s',
|
||||
}}
|
||||
>
|
||||
{entryIndicator && (
|
||||
<div
|
||||
className="review-panel-entry-indicator"
|
||||
onMouseEnter={onEnterEntryIndicator}
|
||||
onMouseLeave={onLeaveEntryIndicator}
|
||||
onMouseDown={openReviewPanel} // Using onMouseDown rather than onClick to guarantee that it fires before onFocus
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<MaterialIcon
|
||||
type={entryIndicator}
|
||||
className="review-panel-entry-icon"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { FC, useCallback, useRef, useState } from 'react'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classNames from 'classnames'
|
||||
import { PreventSelectingEntry } from './review-panel-prevent-selecting'
|
||||
|
||||
export const ExpandableContent: FC<{
|
||||
className?: string
|
||||
content: string
|
||||
contentLimit?: number
|
||||
newLineCharsLimit?: number
|
||||
checkNewLines?: boolean
|
||||
inline?: boolean
|
||||
}> = ({
|
||||
content,
|
||||
className,
|
||||
contentLimit = 50,
|
||||
newLineCharsLimit = 3,
|
||||
checkNewLines = true,
|
||||
inline = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const limit = checkNewLines
|
||||
? Math.min(
|
||||
contentLimit,
|
||||
indexOfNthLine(content, newLineCharsLimit) ?? Infinity
|
||||
)
|
||||
: contentLimit
|
||||
|
||||
const isOverflowing = content.length > limit
|
||||
|
||||
const handleShowMore = useCallback(() => {
|
||||
setIsExpanded(true)
|
||||
contentRef.current?.dispatchEvent(
|
||||
new CustomEvent('review-panel:position', { bubbles: true })
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleShowLess = useCallback(() => {
|
||||
setIsExpanded(false)
|
||||
contentRef.current?.dispatchEvent(
|
||||
new CustomEvent('review-panel:position', { bubbles: true })
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={classNames('review-panel-expandable-content', className)}
|
||||
>
|
||||
{isExpanded ? content : content.slice(0, limit)}
|
||||
{isOverflowing && !isExpanded && '...'}
|
||||
</div>
|
||||
<div
|
||||
className={classNames('review-panel-expandable-links', {
|
||||
'review-panel-expandable-inline': inline,
|
||||
})}
|
||||
>
|
||||
<PreventSelectingEntry>
|
||||
{isExpanded ? (
|
||||
<OLButton
|
||||
variant="link"
|
||||
className="btn-inline-link"
|
||||
onClick={handleShowLess}
|
||||
>
|
||||
{t('show_less')}
|
||||
</OLButton>
|
||||
) : (
|
||||
isOverflowing && (
|
||||
<OLButton
|
||||
variant="link"
|
||||
className="btn-inline-link"
|
||||
onClick={handleShowMore}
|
||||
>
|
||||
{t('show_more')}
|
||||
</OLButton>
|
||||
)
|
||||
)}
|
||||
</PreventSelectingEntry>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function indexOfNthLine(content: string, n: number) {
|
||||
if (n < 1) return null
|
||||
|
||||
let line = 0
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (content[i] === '\n') {
|
||||
line++
|
||||
if (line === n) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { FC, memo, useState } from 'react'
|
||||
import { ReviewPanelResolvedThreadsButton } from './review-panel-resolved-threads-button'
|
||||
import { ReviewPanelTrackChangesMenu } from './review-panel-track-changes-menu'
|
||||
import ReviewPanelTrackChangesMenuButton from './review-panel-track-changes-menu-button'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { PanelHeading } from '@/shared/components/panel-heading'
|
||||
import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
|
||||
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
|
||||
|
||||
const isReviewerRoleEnabled = getMeta('ol-isReviewerRoleEnabled')
|
||||
|
||||
const ReviewPanelHeader: FC = () => {
|
||||
const [trackChangesMenuExpanded, setTrackChangesMenuExpanded] =
|
||||
useState(false)
|
||||
const { setReviewPanelOpen } = useLayoutContext()
|
||||
const { setIsOpen: setRailIsOpen } = useRailContext()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const newEditor = useIsNewEditorEnabled()
|
||||
const handleClose = newEditor
|
||||
? () => setRailIsOpen(false)
|
||||
: () => setReviewPanelOpen(false)
|
||||
|
||||
return (
|
||||
<div className="review-panel-header">
|
||||
<PanelHeading title={t('review')} handleClose={handleClose}>
|
||||
{isReviewerRoleEnabled && <ReviewPanelResolvedThreadsButton />}
|
||||
</PanelHeading>
|
||||
{!isReviewerRoleEnabled && (
|
||||
<div className="review-panel-tools">
|
||||
<ReviewPanelResolvedThreadsButton />
|
||||
<ReviewPanelTrackChangesMenuButton
|
||||
menuExpanded={trackChangesMenuExpanded}
|
||||
setMenuExpanded={setTrackChangesMenuExpanded}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trackChangesMenuExpanded && <ReviewPanelTrackChangesMenu />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ReviewPanelHeader)
|
||||
@@ -0,0 +1,155 @@
|
||||
import { FC, useCallback, useState } from 'react'
|
||||
import {
|
||||
CommentId,
|
||||
ReviewPanelCommentThreadMessage,
|
||||
} from '../../../../../types/review-panel/review-panel'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
|
||||
import ReviewPanelCommentOptions from './review-panel-comment-options'
|
||||
import { ExpandableContent } from './review-panel-expandable-content'
|
||||
import ReviewPanelDeleteCommentModal from './review-panel-delete-comment-modal'
|
||||
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'
|
||||
|
||||
export const ReviewPanelMessage: FC<{
|
||||
message: ReviewPanelCommentThreadMessage
|
||||
hasReplies: boolean
|
||||
isReply: boolean
|
||||
onResolve?: () => Promise<void>
|
||||
onEdit?: (commentId: CommentId, content: string) => Promise<void>
|
||||
onDelete?: () => void
|
||||
isThreadResolved: boolean
|
||||
}> = ({
|
||||
message,
|
||||
isReply,
|
||||
hasReplies,
|
||||
onResolve,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isThreadResolved,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [content, setContent] = useState(message.content)
|
||||
const user = useUserContext()
|
||||
const permissions = usePermissionsContext()
|
||||
|
||||
const isCommentAuthor = user.id === message.user.id
|
||||
const canEdit = isCommentAuthor && permissions.comment
|
||||
const canResolve =
|
||||
permissions.resolveAllComments ||
|
||||
(permissions.resolveOwnComments && isCommentAuthor)
|
||||
const canDelete = canResolve
|
||||
|
||||
const handleEditOption = useCallback(() => 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 handleDelete = useCallback(() => {
|
||||
onDelete?.()
|
||||
setDeleting(false)
|
||||
}, [onDelete])
|
||||
|
||||
return (
|
||||
<div className="review-panel-comment">
|
||||
<div className="review-panel-entry-header">
|
||||
<div>
|
||||
<ReviewPanelEntryUser user={message.user} />
|
||||
<div className="review-panel-entry-time">
|
||||
<FormatTimeBasedOnYear date={message.timestamp} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="review-panel-entry-actions">
|
||||
{!editing && !isReply && !isThreadResolved && canResolve && (
|
||||
<PreventSelectingEntry>
|
||||
<OLTooltip
|
||||
id="resolve-thread"
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
description={t('resolve_comment')}
|
||||
tooltipProps={{ className: 'review-panel-tooltip' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className="btn"
|
||||
onClick={onResolve}
|
||||
>
|
||||
<MaterialIcon
|
||||
type="check"
|
||||
className="review-panel-entry-actions-icon"
|
||||
accessibilityLabel={t('resolve_comment')}
|
||||
/>
|
||||
</button>
|
||||
</OLTooltip>
|
||||
</PreventSelectingEntry>
|
||||
)}
|
||||
|
||||
{!editing && !isThreadResolved && (
|
||||
<PreventSelectingEntry>
|
||||
<ReviewPanelCommentOptions
|
||||
canDelete={canDelete}
|
||||
canEdit={canEdit}
|
||||
onEdit={handleEditOption}
|
||||
onDelete={showDeleteModal}
|
||||
id={message.id}
|
||||
/>
|
||||
</PreventSelectingEntry>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{editing ? (
|
||||
<AutoExpandingTextArea
|
||||
className="review-panel-comment-input review-panel-comment-edit"
|
||||
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
|
||||
/>
|
||||
) : (
|
||||
<ExpandableContent
|
||||
className="review-panel-comment-body"
|
||||
contentLimit={100}
|
||||
checkNewLines
|
||||
content={message.content}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleting && (
|
||||
<ReviewPanelDeleteCommentModal
|
||||
onHide={hideDeleteModal}
|
||||
onDelete={handleDelete}
|
||||
title={hasReplies ? t('delete_comment_thread') : t('delete_comment')}
|
||||
message={
|
||||
hasReplies
|
||||
? t('delete_comment_thread_message')
|
||||
: t('delete_comment_message')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { FC, memo } from 'react'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import classNames from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
const MoreCommentsButton: FC<{
|
||||
onClick: () => void
|
||||
direction: 'upward' | 'downward'
|
||||
}> = ({ onClick, direction }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('review-panel-more-comments-button-container', {
|
||||
downwards: direction === 'downward',
|
||||
upwards: direction === 'upward',
|
||||
})}
|
||||
>
|
||||
<OLButton variant="secondary" size="sm" onClick={onClick}>
|
||||
<MaterialIcon type={`arrow_${direction}_alt`} />
|
||||
{t('more_comments')}
|
||||
</OLButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(MoreCommentsButton)
|
||||
@@ -0,0 +1,12 @@
|
||||
import React, { FC, lazy, Suspense } from 'react'
|
||||
import LoadingSpinner from '@/shared/components/loading-spinner'
|
||||
|
||||
const ReviewPanelContainer = lazy(() => import('./review-panel-container'))
|
||||
|
||||
export const ReviewPanelNew: FC = () => {
|
||||
return (
|
||||
<Suspense fallback={<LoadingSpinner delay={500} />}>
|
||||
<ReviewPanelContainer />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { FC, useMemo } from 'react'
|
||||
import { MainDocument } from '../../../../../types/project-settings'
|
||||
import { Ranges } from '../context/ranges-context'
|
||||
import { ReviewPanelComment } from './review-panel-comment'
|
||||
import { ReviewPanelChange } from './review-panel-change'
|
||||
import {
|
||||
isCommentOperation,
|
||||
isDeleteChange,
|
||||
isInsertChange,
|
||||
} from '@/utils/operations'
|
||||
import {
|
||||
Change,
|
||||
CommentOperation,
|
||||
DeleteOperation,
|
||||
EditOperation,
|
||||
} from '../../../../../types/change'
|
||||
import { canAggregate } from '../utils/can-aggregate'
|
||||
|
||||
import useOverviewFileCollapsed from '../hooks/use-overview-file-collapsed'
|
||||
import { useThreadsContext } from '../context/threads-context'
|
||||
import { CollapsibleFileHeader } from '@/shared/components/collapsible-file-header'
|
||||
|
||||
export const ReviewPanelOverviewFile: FC<{
|
||||
doc: MainDocument
|
||||
ranges: Ranges
|
||||
}> = ({ doc, ranges }) => {
|
||||
const { collapsed, toggleCollapsed } = useOverviewFileCollapsed(doc.doc.id)
|
||||
const threads = useThreadsContext()
|
||||
|
||||
const { aggregates, changes } = useMemo(() => {
|
||||
const changes: Change<EditOperation>[] = []
|
||||
const aggregates: Map<string, Change<DeleteOperation>> = new Map()
|
||||
|
||||
let precedingChange: Change<EditOperation> | null = null
|
||||
for (const change of ranges.changes) {
|
||||
if (
|
||||
precedingChange &&
|
||||
isInsertChange(precedingChange) &&
|
||||
isDeleteChange(change) &&
|
||||
canAggregate(change, precedingChange)
|
||||
) {
|
||||
aggregates.set(precedingChange.id, change)
|
||||
} else {
|
||||
changes.push(change)
|
||||
}
|
||||
precedingChange = change
|
||||
}
|
||||
|
||||
return { aggregates, changes }
|
||||
}, [ranges])
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const unresolvedComments = ranges.comments.filter(comment => {
|
||||
const thread = threads?.[comment.op.t]
|
||||
return thread && thread.messages.length > 0 && !thread.resolved
|
||||
})
|
||||
return [...changes, ...unresolvedComments].sort((a, b) => a.op.p - b.op.p)
|
||||
}, [changes, ranges.comments, threads])
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<CollapsibleFileHeader
|
||||
name={doc.doc.name}
|
||||
count={entries.length}
|
||||
collapsed={collapsed}
|
||||
toggleCollapsed={toggleCollapsed}
|
||||
/>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="review-panel-overview-file-entries">
|
||||
{entries.map(entry =>
|
||||
isCommentOperation(entry.op) ? (
|
||||
<ReviewPanelComment
|
||||
key={entry.id}
|
||||
comment={entry as Change<CommentOperation>}
|
||||
docId={doc.doc.id}
|
||||
hoverRanges={false}
|
||||
/>
|
||||
) : (
|
||||
<ReviewPanelChange
|
||||
key={entry.id}
|
||||
change={entry as Change<EditOperation>}
|
||||
aggregate={aggregates.get(entry.id)}
|
||||
editable={false}
|
||||
docId={doc.doc.id}
|
||||
hoverRanges={false}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="review-panel-overfile-divider" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { FC, useMemo } from 'react'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import { Ranges, useRangesContext } from '../context/ranges-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReviewPanelOverviewFile } from './review-panel-overview-file'
|
||||
import ReviewPanelEmptyState from './review-panel-empty-state'
|
||||
import useProjectRanges from '../hooks/use-project-ranges'
|
||||
|
||||
export const ReviewPanelOverview: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { docs } = useFileTreeData()
|
||||
const docRanges = useRangesContext()
|
||||
|
||||
const { projectRanges, error } = useProjectRanges()
|
||||
|
||||
const rangesForDocs = useMemo(() => {
|
||||
if (docs && docRanges && projectRanges) {
|
||||
const rangesForDocs = new Map<string, Ranges>()
|
||||
|
||||
for (const doc of docs) {
|
||||
const ranges =
|
||||
doc.doc.id === docRanges.docId
|
||||
? docRanges
|
||||
: projectRanges.get(doc.doc.id)
|
||||
|
||||
if (ranges) {
|
||||
rangesForDocs.set(doc.doc.id, ranges)
|
||||
}
|
||||
}
|
||||
|
||||
return rangesForDocs
|
||||
}
|
||||
}, [docRanges, docs, projectRanges])
|
||||
|
||||
const showEmptyState = useMemo((): boolean => {
|
||||
if (!rangesForDocs) {
|
||||
// data isn't loaded yet
|
||||
return false
|
||||
}
|
||||
|
||||
for (const ranges of rangesForDocs.values()) {
|
||||
if (ranges.changes.length > 0 || ranges.comments.length > 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}, [rangesForDocs])
|
||||
|
||||
return (
|
||||
<div className="review-panel-overview">
|
||||
{error && <div>{t('something_went_wrong')}</div>}
|
||||
|
||||
{showEmptyState && <ReviewPanelEmptyState />}
|
||||
|
||||
{docs && rangesForDocs && (
|
||||
<div>
|
||||
{docs.map(doc => {
|
||||
const ranges = rangesForDocs.get(doc.doc.id)
|
||||
return (
|
||||
ranges && <ReviewPanelOverviewFile doc={doc} ranges={ranges} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const stopPropagation = (e: React.FocusEvent | React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
export const PreventSelectingEntry = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
onMouseDown={stopPropagation}
|
||||
onFocus={stopPropagation}
|
||||
onMouseUp={stopPropagation}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { FC } from 'react'
|
||||
import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year'
|
||||
import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReviewPanelEntryUser from './review-panel-entry-user'
|
||||
|
||||
const ReviewPanelResolvedMessage: FC<{
|
||||
thread: ReviewPanelResolvedCommentThread
|
||||
}> = ({ thread }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="review-panel-comment">
|
||||
<div className="review-panel-entry-header">
|
||||
<div>
|
||||
<ReviewPanelEntryUser user={thread.resolved_by_user} />
|
||||
<div className="review-panel-entry-time">
|
||||
<FormatTimeBasedOnYear date={thread.resolved_at} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-panel-comment-body review-panel-resolved-message">
|
||||
<i>{t('marked_as_resolved')}</i>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReviewPanelResolvedMessage
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { FC, useCallback, useState } from 'react'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
||||
import { useThreadsActionsContext } from '../context/threads-context'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { ExpandableContent } from './review-panel-expandable-content'
|
||||
import { ReviewPanelCommentContent } from './review-panel-comment-content'
|
||||
import { Change, CommentOperation } from '../../../../../types/change'
|
||||
import classNames from 'classnames'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
|
||||
export const ReviewPanelResolvedThread: FC<{
|
||||
id: ThreadId
|
||||
comment: Change<CommentOperation>
|
||||
docName: string
|
||||
}> = ({ id, comment, docName }) => {
|
||||
const { t } = useTranslation()
|
||||
const { reopenThread, deleteThread } = useThreadsActionsContext()
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const { showGenericMessageModal } = useModalsContext()
|
||||
const permissions = usePermissionsContext()
|
||||
const user = useUserContext()
|
||||
const isCommentAuthor = user.id === comment.metadata?.user_id
|
||||
const canDelete =
|
||||
permissions.resolveAllComments ||
|
||||
(permissions.resolveOwnComments && isCommentAuthor)
|
||||
const canReopen = permissions.comment
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={classNames('review-panel-resolved-comment', {
|
||||
'review-panel-resolved-disabled': processing,
|
||||
})}
|
||||
key={id}
|
||||
>
|
||||
<div className="review-panel-resolved-comment-header">
|
||||
<div>
|
||||
<Trans
|
||||
i18nKey="from_filename"
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<span className="review-panel-resolved-comment-filename" />,
|
||||
]}
|
||||
values={{ filename: docName }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
</div>
|
||||
<div className="review-panel-resolved-comment-buttons">
|
||||
{canReopen && (
|
||||
<OLTooltip
|
||||
id="reopen-thread"
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
description={t('reopen')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={handleReopenThread}
|
||||
>
|
||||
<MaterialIcon type="refresh" accessibilityLabel={t('reopen')} />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
)}
|
||||
{canDelete && (
|
||||
<OLTooltip
|
||||
id="delete-thread"
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
description={t('delete')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={handleDeleteThread}
|
||||
>
|
||||
<MaterialIcon type="delete" accessibilityLabel={t('delete')} />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-panel-resolved-comment-quoted-text">
|
||||
<div className="review-panel-resolved-comment-quoted-text-label">
|
||||
{t('quoted_text')}
|
||||
</div>
|
||||
<ExpandableContent
|
||||
className="review-panel-resolved-comment-quoted-text-quote"
|
||||
content={comment?.op.c}
|
||||
checkNewLines
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ReviewPanelCommentContent comment={comment} isResolved />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { FC, useRef, useState } from 'react'
|
||||
import OLOverlay from '@/features/ui/components/ol/ol-overlay'
|
||||
import OLPopover from '@/features/ui/components/ol/ol-popover'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import { ReviewPanelResolvedThreadsMenu } from './review-panel-resolved-threads-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
export const ReviewPanelResolvedThreadsButton: FC = () => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLTooltip
|
||||
id="resolved-comments"
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
description={t('resolved_comments')}
|
||||
>
|
||||
<button
|
||||
className={
|
||||
getMeta('ol-isReviewerRoleEnabled')
|
||||
? 'review-panel-resolved-comments-toggle-reviewer-role'
|
||||
: 'review-panel-resolved-comments-toggle'
|
||||
}
|
||||
ref={buttonRef}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<MaterialIcon type="inbox" />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
{expanded && (
|
||||
<OLOverlay
|
||||
show
|
||||
onHide={() => setExpanded(false)}
|
||||
transition={false}
|
||||
container={this}
|
||||
containerPadding={0}
|
||||
placement="bottom"
|
||||
rootClose
|
||||
target={buttonRef.current}
|
||||
>
|
||||
<OLPopover
|
||||
id="popover-resolved-threads"
|
||||
className="review-panel-resolved-comments"
|
||||
>
|
||||
<ReviewPanelResolvedThreadsMenu />
|
||||
</OLPopover>
|
||||
</OLOverlay>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { FC, useMemo } from 'react'
|
||||
import { useThreadsContext } from '../context/threads-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReviewPanelResolvedThread } from './review-panel-resolved-thread'
|
||||
import useProjectRanges from '../hooks/use-project-ranges'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import { Change, CommentOperation } from '../../../../../types/change'
|
||||
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
||||
import LoadingSpinner from '@/shared/components/loading-spinner'
|
||||
import OLBadge from '@/features/ui/components/ol/ol-badge'
|
||||
|
||||
export const ReviewPanelResolvedThreadsMenu: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const threads = useThreadsContext()
|
||||
const { docs } = useFileTreeData()
|
||||
|
||||
const { projectRanges, loading } = useProjectRanges()
|
||||
|
||||
const docNameForThread = useMemo(() => {
|
||||
const docNameForThread = new Map<string, string>()
|
||||
|
||||
for (const [docId, ranges] of projectRanges?.entries() ?? []) {
|
||||
const docName = docs?.find(doc => doc.doc.id === docId)?.doc.name
|
||||
if (docName !== undefined) {
|
||||
for (const comment of ranges.comments) {
|
||||
const threadId = comment.op.t
|
||||
docNameForThread.set(threadId, docName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return docNameForThread
|
||||
}, [docs, projectRanges])
|
||||
|
||||
const allComments = useMemo(() => {
|
||||
const allComments = new Map<string, Change<CommentOperation>>()
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
for (const [_, ranges] of projectRanges?.entries() ?? []) {
|
||||
for (const comment of ranges.comments) {
|
||||
allComments.set(comment.op.t, comment)
|
||||
}
|
||||
}
|
||||
|
||||
return allComments
|
||||
}, [projectRanges])
|
||||
|
||||
const resolvedThreads = useMemo(() => {
|
||||
if (!threads) {
|
||||
return []
|
||||
}
|
||||
|
||||
const allResolvedThreads = []
|
||||
for (const [id, thread] of Object.entries(threads)) {
|
||||
if (thread.resolved) {
|
||||
allResolvedThreads.push({ thread, id })
|
||||
}
|
||||
}
|
||||
allResolvedThreads.sort((a, b) => {
|
||||
return Date.parse(b.thread.resolved_at) - Date.parse(a.thread.resolved_at)
|
||||
})
|
||||
|
||||
return allResolvedThreads.filter(thread => allComments.has(thread.id))
|
||||
}, [threads, allComments])
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner className="ms-auto me-auto" />
|
||||
}
|
||||
|
||||
if (!resolvedThreads.length) {
|
||||
return (
|
||||
<div className="review-panel-resolved-comments-empty">
|
||||
{t('no_resolved_comments')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="review-panel-resolved-comments-header">
|
||||
<div className="review-panel-resolved-comments-label">
|
||||
{t('resolved_comments')}
|
||||
</div>
|
||||
<OLBadge
|
||||
bg="light"
|
||||
text="dark"
|
||||
className="review-panel-resolved-comments-count"
|
||||
>
|
||||
{resolvedThreads.length}
|
||||
</OLBadge>
|
||||
</div>
|
||||
{resolvedThreads.map(thread => {
|
||||
const comment = allComments.get(thread.id)
|
||||
if (!comment) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ReviewPanelResolvedThread
|
||||
key={thread.id}
|
||||
id={thread.id as ThreadId}
|
||||
comment={comment}
|
||||
docName={docNameForThread.get(thread.id) ?? t('unknown')}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { FC, memo } from 'react'
|
||||
import classnames from 'classnames'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useReviewPanelViewActionsContext,
|
||||
useReviewPanelViewContext,
|
||||
} from '../context/review-panel-view-context'
|
||||
|
||||
const ReviewPanelTabs: FC = () => {
|
||||
const subView = useReviewPanelViewContext()
|
||||
const { setView: setSubView } = useReviewPanelViewActionsContext()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={classnames('review-panel-tab', {
|
||||
'review-panel-tab-active': subView === 'cur_file',
|
||||
})}
|
||||
onClick={() => setSubView('cur_file')}
|
||||
>
|
||||
<MaterialIcon type="description" />
|
||||
{t('current_file')}
|
||||
</button>
|
||||
<button
|
||||
className={classnames('review-panel-tab', {
|
||||
'review-panel-tab-active': subView === 'overview',
|
||||
})}
|
||||
onClick={() => setSubView('overview')}
|
||||
>
|
||||
<MaterialIcon type="list" />
|
||||
{t('overview')}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ReviewPanelTabs)
|
||||
@@ -0,0 +1,60 @@
|
||||
import { FC, memo, useState } from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import UpgradeTrackChangesModalLegacy from './upgrade-track-changes-modal-legacy'
|
||||
import { send, sendMB } from '@/infrastructure/event-tracking'
|
||||
|
||||
const sendAnalytics = () => {
|
||||
send('subscription-funnel', 'editor-click-feature', 'real-time-track-changes')
|
||||
sendMB('paywall-prompt', {
|
||||
'paywall-type': 'track-changes',
|
||||
})
|
||||
}
|
||||
|
||||
const ReviewPanelTrackChangesMenuButton: FC<{
|
||||
menuExpanded: boolean
|
||||
setMenuExpanded: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}> = ({ menuExpanded, setMenuExpanded }) => {
|
||||
const project = useProjectContext()
|
||||
const { wantTrackChanges } = useEditorManagerContext()
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const handleTrackChangesMenuExpand = () => {
|
||||
if (project.features.trackChanges) {
|
||||
setMenuExpanded(value => !value)
|
||||
} else {
|
||||
sendAnalytics()
|
||||
setShowModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="track-changes-menu-button"
|
||||
onClick={handleTrackChangesMenuExpand}
|
||||
>
|
||||
{wantTrackChanges && <div className="track-changes-indicator-circle" />}
|
||||
{wantTrackChanges ? (
|
||||
<Trans
|
||||
i18nKey="track_changes_is_on"
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="track_changes_is_off"
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
)}
|
||||
<MaterialIcon type={menuExpanded ? 'expand_more' : 'chevron_right'} />
|
||||
</button>
|
||||
|
||||
<UpgradeTrackChangesModalLegacy show={showModal} setShow={setShowModal} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ReviewPanelTrackChangesMenuButton)
|
||||
@@ -0,0 +1,92 @@
|
||||
import { FC } from 'react'
|
||||
import TrackChangesToggle from '@/features/review-panel-new/components/track-changes-toggle'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useTrackChangesStateActionsContext,
|
||||
useTrackChangesStateContext,
|
||||
} from '../context/track-changes-state-context'
|
||||
import { useChangesUsersContext } from '../context/changes-users-context'
|
||||
import { buildName } from '../utils/build-name'
|
||||
|
||||
export const ReviewPanelTrackChangesMenu: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const permissions = usePermissionsContext()
|
||||
const project = useProjectContext()
|
||||
const trackChanges = useTrackChangesStateContext()
|
||||
const { saveTrackChanges } = useTrackChangesStateActionsContext()
|
||||
const changesUsers = useChangesUsersContext()
|
||||
|
||||
if (trackChanges === undefined || !changesUsers) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { onForEveryone, onForGuests, onForMembers } = trackChanges
|
||||
|
||||
const canToggle = project.features.trackChanges && permissions.write
|
||||
|
||||
return (
|
||||
<div className="rp-tc-state">
|
||||
<div className="rp-tc-state-item">
|
||||
<span className="rp-tc-state-item-name">{t('tc_everyone')}</span>
|
||||
|
||||
<TrackChangesToggle
|
||||
id="track-changes-everyone"
|
||||
description={t('track_changes_for_everyone')}
|
||||
handleToggle={() =>
|
||||
saveTrackChanges(onForEveryone ? { on_for: {} } : { on: true })
|
||||
}
|
||||
value={onForEveryone}
|
||||
disabled={!canToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{[project.owner, ...project.members].map(member => {
|
||||
const user = changesUsers.get(member._id) ?? member
|
||||
const name = buildName(user)
|
||||
|
||||
const value = onForEveryone || onForMembers[member._id] === true
|
||||
|
||||
return (
|
||||
<div key={member._id} className="rp-tc-state-item">
|
||||
<span className="rp-tc-state-item-name">{name}</span>
|
||||
|
||||
<TrackChangesToggle
|
||||
id={`track-changes-${member._id}`}
|
||||
description={t('track_changes_for_x', { name })}
|
||||
handleToggle={() => {
|
||||
saveTrackChanges({
|
||||
on_for: {
|
||||
...onForMembers,
|
||||
[member._id]: !value,
|
||||
},
|
||||
on_for_guests: onForGuests,
|
||||
})
|
||||
}}
|
||||
value={value}
|
||||
disabled={!canToggle || onForEveryone}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="rp-tc-state-item">
|
||||
<span className="rp-tc-state-item-name">{t('tc_guests')}</span>
|
||||
|
||||
<TrackChangesToggle
|
||||
id="track-changes-guests"
|
||||
description={t('track_changes_for_guests')}
|
||||
handleToggle={() =>
|
||||
saveTrackChanges({
|
||||
on_for: onForMembers,
|
||||
on_for_guests: !onForGuests,
|
||||
})
|
||||
}
|
||||
value={onForGuests}
|
||||
disabled={!canToggle || onForEveryone}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { FC, memo, useMemo } from 'react'
|
||||
import ReviewPanelTabs from './review-panel-tabs'
|
||||
import ReviewPanelHeader from './review-panel-header'
|
||||
import ReviewPanelCurrentFile from './review-panel-current-file'
|
||||
import { ReviewPanelOverview } from './review-panel-overview'
|
||||
import classnames from 'classnames'
|
||||
import { useReviewPanelStyles } from '@/features/review-panel-new/hooks/use-review-panel-styles'
|
||||
import { useReviewPanelViewContext } from '../context/review-panel-view-context'
|
||||
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
|
||||
|
||||
const ReviewPanel: FC<{ mini?: boolean }> = ({ mini = false }) => {
|
||||
const choosenSubView = useReviewPanelViewContext()
|
||||
const newEditor = useIsNewEditorEnabled()
|
||||
|
||||
const activeSubView = useMemo(
|
||||
() => (mini ? 'cur_file' : choosenSubView),
|
||||
[choosenSubView, mini]
|
||||
)
|
||||
|
||||
const style = useReviewPanelStyles(mini)
|
||||
|
||||
const className = classnames('review-panel-container', {
|
||||
'review-panel-mini': mini,
|
||||
'review-panel-subview-overview': activeSubView === 'overview',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<div id="review-panel-inner" className="review-panel-inner">
|
||||
{!newEditor && !mini && <ReviewPanelHeader />}
|
||||
|
||||
{activeSubView === 'cur_file' && <ReviewPanelCurrentFile />}
|
||||
{activeSubView === 'overview' && <ReviewPanelOverview />}
|
||||
|
||||
<div className="review-panel-footer">
|
||||
<ReviewPanelTabs />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ReviewPanel)
|
||||
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
CSSProperties,
|
||||
FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useCodeMirrorStateContext,
|
||||
useCodeMirrorViewContext,
|
||||
} from '@/features/source-editor/components/codemirror-context'
|
||||
import {
|
||||
buildAddNewCommentRangeEffect,
|
||||
reviewTooltipStateField,
|
||||
} from '@/features/source-editor/extensions/review-tooltip'
|
||||
import { EditorView, getTooltip } from '@codemirror/view'
|
||||
import useViewerPermissions from '@/shared/hooks/use-viewer-permissions'
|
||||
import usePreviousValue from '@/shared/hooks/use-previous-value'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import { useReviewPanelViewActionsContext } from '../context/review-panel-view-context'
|
||||
import {
|
||||
useRangesActionsContext,
|
||||
useRangesContext,
|
||||
} from '../context/ranges-context'
|
||||
import { isInsertOperation } from '@/utils/operations'
|
||||
import { isCursorNearViewportEdge } from '@/features/source-editor/utils/is-cursor-near-edge'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||
import { numberOfChangesInSelection } from '../utils/changes-in-selection'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
import classNames from 'classnames'
|
||||
import useEventListener from '@/shared/hooks/use-event-listener'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
|
||||
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
|
||||
|
||||
const isReviewerRoleEnabled = getMeta('ol-isReviewerRoleEnabled')
|
||||
const TRACK_CHANGES_ON_WIDGET_HEIGHT = 25
|
||||
const EDIT_MODE_SWITCH_WIDGET_HEIGHT = 40
|
||||
const CM_LINE_RIGHT_PADDING = isReviewerRoleEnabled ? 8 : 2
|
||||
const TOOLTIP_SHOW_DELAY = 120
|
||||
|
||||
const ReviewTooltipMenu: FC = () => {
|
||||
const state = useCodeMirrorStateContext()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const isViewer = useViewerPermissions()
|
||||
const [show, setShow] = useState(true)
|
||||
const { setView } = useReviewPanelViewActionsContext()
|
||||
const { setReviewPanelOpen } = useLayoutContext()
|
||||
const { openTab: openRailTab } = useRailContext()
|
||||
const newEditor = useIsNewEditorEnabled()
|
||||
|
||||
const tooltipState = state.field(reviewTooltipStateField, false)?.tooltip
|
||||
const previousTooltipState = usePreviousValue(tooltipState)
|
||||
|
||||
useEffect(() => {
|
||||
if (tooltipState !== null && previousTooltipState === null) {
|
||||
setShow(true)
|
||||
}
|
||||
}, [tooltipState, previousTooltipState])
|
||||
|
||||
const addComment = useCallback(() => {
|
||||
const { main } = view.state.selection
|
||||
if (main.empty) {
|
||||
return
|
||||
}
|
||||
|
||||
if (newEditor) {
|
||||
openRailTab('review-panel')
|
||||
} else {
|
||||
setReviewPanelOpen(true)
|
||||
}
|
||||
setView('cur_file')
|
||||
|
||||
const effects = isCursorNearViewportEdge(view, main.anchor)
|
||||
? [
|
||||
buildAddNewCommentRangeEffect(main),
|
||||
EditorView.scrollIntoView(main.anchor, { y: 'center' }),
|
||||
]
|
||||
: [buildAddNewCommentRangeEffect(main)]
|
||||
|
||||
view.dispatch({ effects })
|
||||
setShow(false)
|
||||
}, [setReviewPanelOpen, setView, setShow, view, openRailTab, newEditor])
|
||||
|
||||
useEventListener('add-new-review-comment', addComment)
|
||||
|
||||
if (isViewer || !show || !tooltipState) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tooltipView = getTooltip(view, tooltipState)
|
||||
|
||||
if (!tooltipView) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<ReviewTooltipMenuContent onAddComment={addComment} />,
|
||||
tooltipView.dom
|
||||
)
|
||||
}
|
||||
|
||||
const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({
|
||||
onAddComment,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const state = useCodeMirrorStateContext()
|
||||
const { reviewPanelOpen } = useLayoutContext()
|
||||
const ranges = useRangesContext()
|
||||
const { acceptChanges, rejectChanges } = useRangesActionsContext()
|
||||
const { showGenericConfirmModal } = useModalsContext()
|
||||
const { wantTrackChanges } = useEditorManagerContext()
|
||||
const [tooltipStyle, setTooltipStyle] = useState<CSSProperties | undefined>()
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
const changeIdsInSelection = useMemo(() => {
|
||||
return (ranges?.changes ?? [])
|
||||
.filter(({ op }) => {
|
||||
const opFrom = op.p
|
||||
const opLength = isInsertOperation(op) ? op.i.length : 0
|
||||
const opTo = opFrom + opLength
|
||||
const selection = state.selection.main
|
||||
return opFrom >= selection.from && opTo <= selection.to
|
||||
})
|
||||
.map(({ id }) => id)
|
||||
}, [ranges, state.selection.main])
|
||||
|
||||
const acceptChangesHandler = useCallback(() => {
|
||||
const nChanges = numberOfChangesInSelection(
|
||||
ranges,
|
||||
view.state.selection.main
|
||||
)
|
||||
showGenericConfirmModal({
|
||||
message: t('confirm_accept_selected_changes', { count: nChanges }),
|
||||
title: t('accept_selected_changes'),
|
||||
onConfirm: () => {
|
||||
acceptChanges(...changeIdsInSelection)
|
||||
},
|
||||
primaryVariant: 'danger',
|
||||
})
|
||||
}, [
|
||||
acceptChanges,
|
||||
changeIdsInSelection,
|
||||
ranges,
|
||||
showGenericConfirmModal,
|
||||
view,
|
||||
t,
|
||||
])
|
||||
|
||||
const rejectChangesHandler = useCallback(() => {
|
||||
const nChanges = numberOfChangesInSelection(
|
||||
ranges,
|
||||
view.state.selection.main
|
||||
)
|
||||
showGenericConfirmModal({
|
||||
message: t('confirm_reject_selected_changes', { count: nChanges }),
|
||||
title: t('reject_selected_changes'),
|
||||
onConfirm: () => {
|
||||
rejectChanges(...changeIdsInSelection)
|
||||
},
|
||||
primaryVariant: 'danger',
|
||||
})
|
||||
}, [
|
||||
showGenericConfirmModal,
|
||||
t,
|
||||
ranges,
|
||||
view,
|
||||
rejectChanges,
|
||||
changeIdsInSelection,
|
||||
])
|
||||
|
||||
const showChangesButtons = changeIdsInSelection.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
view.requestMeasure({
|
||||
key: 'review-tooltip-outside-viewport',
|
||||
read(view) {
|
||||
const cursorCoords = view.coordsAtPos(view.state.selection.main.head)
|
||||
|
||||
if (!cursorCoords) {
|
||||
return
|
||||
}
|
||||
|
||||
const scrollDomRect = view.scrollDOM.getBoundingClientRect()
|
||||
const contentDomRect = view.contentDOM.getBoundingClientRect()
|
||||
const editorRightPos = contentDomRect.right - CM_LINE_RIGHT_PADDING
|
||||
|
||||
if (
|
||||
cursorCoords.top > scrollDomRect.top &&
|
||||
cursorCoords.top < scrollDomRect.bottom
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
let widgetOffset = 0
|
||||
if (isReviewerRoleEnabled) {
|
||||
widgetOffset = EDIT_MODE_SWITCH_WIDGET_HEIGHT
|
||||
} else if (wantTrackChanges && !reviewPanelOpen) {
|
||||
widgetOffset = TRACK_CHANGES_ON_WIDGET_HEIGHT
|
||||
}
|
||||
|
||||
return {
|
||||
position: 'fixed' as const,
|
||||
top: scrollDomRect.top + widgetOffset,
|
||||
right: window.innerWidth - editorRightPos,
|
||||
}
|
||||
},
|
||||
write(res) {
|
||||
setTooltipStyle(res)
|
||||
},
|
||||
})
|
||||
}, [view, reviewPanelOpen, wantTrackChanges])
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(false)
|
||||
const timeout = setTimeout(() => {
|
||||
setVisible(true)
|
||||
}, TOOLTIP_SHOW_DELAY)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('review-tooltip-menu', {
|
||||
'review-tooltip-menu-visible': visible,
|
||||
})}
|
||||
style={tooltipStyle}
|
||||
>
|
||||
<button
|
||||
className="review-tooltip-menu-button review-tooltip-add-comment-button"
|
||||
onClick={onAddComment}
|
||||
>
|
||||
<MaterialIcon type="chat" />
|
||||
{t('add_comment')}
|
||||
</button>
|
||||
{showChangesButtons && (
|
||||
<>
|
||||
<div className="review-tooltip-menu-divider" />
|
||||
<OLTooltip
|
||||
id="accept-all-changes"
|
||||
description={t('accept_selected_changes')}
|
||||
>
|
||||
<button
|
||||
className="review-tooltip-menu-button"
|
||||
onClick={acceptChangesHandler}
|
||||
>
|
||||
<MaterialIcon type="check" />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
|
||||
<OLTooltip
|
||||
id="reject-all-changes"
|
||||
description={t('reject_selected_changes')}
|
||||
>
|
||||
<button
|
||||
className="review-tooltip-menu-button"
|
||||
onClick={rejectChangesHandler}
|
||||
>
|
||||
<MaterialIcon type="clear" />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReviewTooltipMenu
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Trans } from 'react-i18next'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import classnames from 'classnames'
|
||||
import { useCodeMirrorStateContext } from '@/features/source-editor/components/codemirror-context'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
function TrackChangesOnWidget() {
|
||||
const { setReviewPanelOpen } = useLayoutContext()
|
||||
const state = useCodeMirrorStateContext()
|
||||
const darkTheme = state.facet(EditorView.darkTheme)
|
||||
|
||||
const openReviewPanel = useCallback(() => {
|
||||
setReviewPanelOpen(true)
|
||||
}, [setReviewPanelOpen])
|
||||
|
||||
return (
|
||||
<div className="review-panel-in-editor-widgets">
|
||||
<div className="review-panel-in-editor-widgets-inner">
|
||||
<button
|
||||
className={classnames('review-panel-track-changes-indicator', {
|
||||
'review-panel-track-changes-indicator-on-dark': darkTheme,
|
||||
})}
|
||||
onClick={openReviewPanel}
|
||||
>
|
||||
<Trans
|
||||
i18nKey="track_changes_is_on"
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackChangesOnWidget
|
||||
@@ -0,0 +1,29 @@
|
||||
import OLFormSwitch from '@/features/ui/components/ol/ol-form-switch'
|
||||
|
||||
type TrackChangesToggleProps = {
|
||||
id: string
|
||||
description: string
|
||||
disabled: boolean
|
||||
handleToggle: () => void
|
||||
value: boolean
|
||||
}
|
||||
|
||||
function TrackChangesToggle({
|
||||
id,
|
||||
description,
|
||||
disabled,
|
||||
handleToggle,
|
||||
value,
|
||||
}: TrackChangesToggleProps) {
|
||||
return (
|
||||
<OLFormSwitch
|
||||
id={`input-switch-${id}`}
|
||||
disabled={disabled}
|
||||
onChange={handleToggle}
|
||||
checked={value}
|
||||
label={description}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackChangesToggle
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
import teaserVideo from '../images/teaser-track-changes.mp4'
|
||||
import teaserImage from '../images/teaser-track-changes.gif'
|
||||
import { startFreeTrial, upgradePlan } from '@/main/account-upgrade'
|
||||
import { memo } from 'react'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLRow from '@/features/ui/components/ol/ol-row'
|
||||
import OLCol from '@/features/ui/components/ol/ol-col'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
type UpgradeTrackChangesModalProps = {
|
||||
show: boolean
|
||||
setShow: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
function UpgradeTrackChangesModalLegacy({
|
||||
show,
|
||||
setShow,
|
||||
}: UpgradeTrackChangesModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const project = useProjectContext()
|
||||
const user = useUserContext()
|
||||
|
||||
return (
|
||||
<OLModal show={show} onHide={() => setShow(false)}>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>{t('upgrade_to_track_changes')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
<OLModalBody>
|
||||
<div className="teaser-video-container">
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video className="teaser-video" autoPlay loop>
|
||||
<source src={teaserVideo} type="video/mp4" />
|
||||
<img
|
||||
src={teaserImage}
|
||||
alt={t('demonstrating_track_changes_feature')}
|
||||
/>
|
||||
</video>
|
||||
</div>
|
||||
<h4 className="teaser-title">
|
||||
{t('see_changes_in_your_documents_live')}
|
||||
</h4>
|
||||
<OLRow>
|
||||
<OLCol lg={{ span: 10, offset: 1 }}>
|
||||
<ul className="list-unstyled">
|
||||
{[
|
||||
t('track_any_change_in_real_time'),
|
||||
t('review_your_peers_work'),
|
||||
t('accept_or_reject_each_changes_individually'),
|
||||
].map(translation => (
|
||||
<li key={translation}>
|
||||
<MaterialIcon type="check" className="align-text-bottom" />
|
||||
{translation}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
<p className="small">
|
||||
{t('already_subscribed_try_refreshing_the_page')}
|
||||
</p>
|
||||
{project.owner && (
|
||||
<div className="text-center">
|
||||
{project.owner._id === user.id ? (
|
||||
user.allowedFreeTrial ? (
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={() => startFreeTrial('track-changes')}
|
||||
>
|
||||
{t('try_it_for_free')}
|
||||
</OLButton>
|
||||
) : (
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={() => upgradePlan('project-sharing')}
|
||||
>
|
||||
{t('upgrade')}
|
||||
</OLButton>
|
||||
)
|
||||
) : (
|
||||
<p>
|
||||
<strong>
|
||||
{t(
|
||||
'please_ask_the_project_owner_to_upgrade_to_track_changes'
|
||||
)}
|
||||
</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={() => setShow(false)}>
|
||||
{t('close')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(UpgradeTrackChangesModalLegacy)
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
import teaserVideo from '../images/teaser-track-changes.mp4'
|
||||
import teaserImage from '../images/teaser-track-changes.gif'
|
||||
import { startFreeTrial, upgradePlan } from '@/main/account-upgrade'
|
||||
import { memo } from 'react'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLRow from '@/features/ui/components/ol/ol-row'
|
||||
import OLCol from '@/features/ui/components/ol/ol-col'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
type UpgradeTrackChangesModalProps = {
|
||||
show: boolean
|
||||
setShow: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
function UpgradeTrackChangesModal({
|
||||
show,
|
||||
setShow,
|
||||
}: UpgradeTrackChangesModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const project = useProjectContext()
|
||||
const user = useUserContext()
|
||||
|
||||
return (
|
||||
<OLModal show={show} onHide={() => setShow(false)}>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>{t('upgrade_to_review')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
<OLModalBody className="upgrade-track-changes-modal">
|
||||
<div className="teaser-video-container">
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video className="teaser-video" autoPlay loop>
|
||||
<source src={teaserVideo} type="video/mp4" />
|
||||
<img
|
||||
src={teaserImage}
|
||||
alt={t('demonstrating_track_changes_feature')}
|
||||
/>
|
||||
</video>
|
||||
</div>
|
||||
<h4 className="teaser-title">{t('get_real_time_track_changes')}</h4>
|
||||
<OLRow>
|
||||
<OLCol lg={{ span: 10, offset: 1 }}>
|
||||
<ul className="list-unstyled">
|
||||
{[
|
||||
t('see_suggestions_from_collaborators'),
|
||||
t('accept_or_reject_individual_edits'),
|
||||
t('access_all_premium_features'),
|
||||
].map(translation => (
|
||||
<li key={translation}>
|
||||
<MaterialIcon type="check" className="check-icon" />
|
||||
<span>{translation}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
{project.owner && (
|
||||
<div className="text-center">
|
||||
{project.owner._id === user.id ? (
|
||||
user.allowedFreeTrial ? (
|
||||
<OLButton
|
||||
variant="premium"
|
||||
onClick={() => startFreeTrial('track-changes')}
|
||||
>
|
||||
{t('try_it_for_free')}
|
||||
</OLButton>
|
||||
) : (
|
||||
<OLButton
|
||||
variant="premium"
|
||||
onClick={() => upgradePlan('project-sharing')}
|
||||
>
|
||||
{t('upgrade')}
|
||||
</OLButton>
|
||||
)
|
||||
) : (
|
||||
<p>
|
||||
<strong>
|
||||
{t(
|
||||
'please_ask_the_project_owner_to_upgrade_to_track_changes'
|
||||
)}
|
||||
</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={() => setShow(false)}>
|
||||
{t('close')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(UpgradeTrackChangesModal)
|
||||
Reference in New Issue
Block a user