diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-action-icons.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-action-icons.tsx new file mode 100644 index 0000000000..717f7a94ff --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-action-icons.tsx @@ -0,0 +1,29 @@ +import { memo } from 'react' +import MaterialIcon from '@/shared/components/material-icon' + +export const AddIcon = memo(function AddIcon() { + return ( + + ) +}) + +export const DeleteIcon = memo(function DeleteIcon() { + return ( + + ) +}) + +export const EditIcon = memo(function EditIcon() { + return ( + + ) +}) diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-change-action.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-change-action.tsx new file mode 100644 index 0000000000..532dc9a466 --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-change-action.tsx @@ -0,0 +1,36 @@ +import Tooltip from '@/features/ui/components/bootstrap-5/tooltip' +import { ComponentProps, memo, MouseEventHandler } from 'react' +import { PreventSelectingEntry } from '@/features/review-panel-new/components/review-panel-prevent-selecting' +import OLTooltip from '@/features/ui/components/ol/ol-tooltip' +import MaterialIcon from '@/shared/components/material-icon' + +const changeActionTooltipProps: Partial> = { + overlayProps: { placement: 'bottom' }, + tooltipProps: { className: 'review-panel-tooltip' }, +} + +export const ChangeAction = memo<{ + id: string + label: string + type: string + handleClick: MouseEventHandler +}>(function ChangeAction({ id, label, type, handleClick }) { + return ( + + + + + + ) +}) diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-change.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-change.tsx index 06d10ece2b..a153b1e1d4 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-change.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-change.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { useRangesActionsContext } from '../context/ranges-context' import { Change, @@ -8,8 +8,6 @@ import { 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' @@ -17,7 +15,12 @@ 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' +import { ChangeAction } from '@/features/review-panel-new/components/review-panel-change-action' +import { + AddIcon, + DeleteIcon, + EditIcon, +} from '@/features/review-panel-new/components/review-panel-action-icons' export const ReviewPanelChange = memo<{ change: Change @@ -27,8 +30,8 @@ export const ReviewPanelChange = memo<{ docId: string hoverRanges?: boolean hovered?: boolean - onEnter?: (changeId: string) => void - onLeave?: (changeId: string) => void + handleEnter?: (changeId: string) => void + handleLeave?: () => void }>( ({ change, @@ -38,8 +41,8 @@ export const ReviewPanelChange = memo<{ hoverRanges, editable = true, hovered, - onEnter, - onLeave, + handleEnter, + handleLeave, }) => { const { t } = useTranslation() const { acceptChanges, rejectChanges } = useRangesActionsContext() @@ -68,6 +71,34 @@ export const ReviewPanelChange = memo<{ } }, [acceptChanges, aggregate, change.id, showGenericMessageModal, t]) + const rejectHandler = useCallback(async () => { + if (aggregate) { + await rejectChanges(change.id, aggregate.id) + } else { + await rejectChanges(change.id) + } + }, [aggregate, change, rejectChanges]) + + const translations = useMemo( + () => ({ + accept_change: t('accept_change'), + reject_change: t('reject_change'), + aggregate_changed: t('aggregate_changed'), + aggregate_to: t('aggregate_to'), + tracked_change_added: t('tracked_change_added'), + tracked_change_deleted: t('tracked_change_deleted'), + }), + [t] + ) + + const { handleMouseEnter, handleMouseLeave } = useMemo( + () => ({ + handleMouseEnter: handleEnter && (() => handleEnter(change.id)), + handleMouseLeave: handleLeave && (() => handleLeave()), + }), + [change.id, handleEnter, handleLeave] + ) + if (!changesUsers) { // if users are not loaded yet, do not show "Unknown" user return null @@ -90,14 +121,14 @@ export const ReviewPanelChange = memo<{ docId={docId} hoverRanges={hoverRanges} disabled={accepting} - onEnterEntryIndicator={onEnter && (() => onEnter(change.id))} - onLeaveEntryIndicator={onLeave && (() => onLeave(change.id))} + handleEnter={handleMouseEnter} + handleLeave={handleMouseLeave} entryIndicator="edit" >
onEnter(change.id))} - onMouseLeave={onLeave && (() => onLeave(change.id))} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} >
@@ -111,56 +142,22 @@ export const ReviewPanelChange = memo<{ {editable && (
{permissions.write && ( - - - - - + )} {(permissions.write || (permissions.trackedWrite && isChangeAuthor)) && ( - - - - - + )}
)} @@ -169,21 +166,11 @@ export const ReviewPanelChange = memo<{
{'i' in change.op && ( <> - {aggregateChange ? ( - - ) : ( - - )} + {aggregateChange ? : } {aggregateChange ? ( - {t('aggregate_changed')}:{' '} + {translations.aggregate_changed}:{' '} {' '} - {t('aggregate_to')}{' '} + {translations.aggregate_to}{' '} ) : ( - {t('tracked_change_added')}:  + {translations.tracked_change_added}:  - - + - {t('tracked_change_deleted')}:  + {translations.tracked_change_deleted}:  Promise onDeleteThread?: (threadId: ThreadId) => Promise onResolve?: () => Promise - onLeave?: (changeId: string) => void - onEnter?: (changeId: string) => void + onLeave?: () => void + onEnter?: () => void }>( ({ comment, @@ -69,8 +69,8 @@ export const ReviewPanelCommentContent = memo<{ return (
onEnter(comment.id))} - onMouseLeave={onLeave && (() => onLeave(comment.id))} + onMouseEnter={onEnter} + onMouseLeave={onLeave} > {thread.messages.map((message, i) => { const isReply = i !== 0 diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx index dd6366f091..c7f60aac35 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { Change, CommentOperation } from '../../../../../types/change' import { useThreadsActionsContext, @@ -21,160 +21,169 @@ export const ReviewPanelComment = memo<{ docId: string top?: number hoverRanges?: boolean - onEnter?: (changeId: string) => void - onLeave?: (changeId: string) => void + handleEnter?: (changeId: string) => void + handleLeave?: (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, +}>( + ({ comment, top, hovered, handleEnter, handleLeave, docId, hoverRanges }) => { + const threads = useThreadsContext() + const { + resolveThread, + editMessage, deleteMessage, deleteOwnMessage, - showGenericMessageModal, - t, - permissions.resolveOwnComments, - permissions.resolveAllComments, - ] - ) + deleteThread, + addMessage, + } = useThreadsActionsContext() + const { showGenericMessageModal } = useModalsContext() + const { t } = useTranslation() + const permissions = usePermissionsContext() - const handleDeleteThread = useCallback( - async (commentId: ThreadId) => { + const [processing, setProcessing] = useState(false) + + const handleResolveComment = useCallback(async () => { setProcessing(true) try { - await deleteThread(commentId) + await resolveThread(comment.op.t) } catch (err) { debugConsole.error(err) showGenericMessageModal( - t('delete_comment_error_title'), - t('delete_comment_error_message') + t('resolve_comment_error_title'), + t('resolve_comment_error_message') ) } finally { setProcessing(false) } - }, - [deleteThread, showGenericMessageModal, t] - ) + }, [comment.op.t, resolveThread, 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) + 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 { handleMouseEnter, handleMouseLeave } = useMemo(() => { + return { + handleMouseEnter: handleEnter && (() => handleEnter(comment.id)), + handleMouseLeave: handleLeave && (() => handleLeave(comment.id)), } - }, - [addMessage, comment.op.t, showGenericMessageModal, t] - ) + }, [comment.id, handleEnter, handleLeave]) - const thread = threads?.[comment.op.t] - if (!thread || thread.resolved || thread.messages.length === 0) { - return null + const thread = threads?.[comment.op.t] + if (!thread || thread.resolved || thread.messages.length === 0) { + return null + } + + return ( + + + + ) } - - return ( - onEnter(comment.id))} - onLeaveEntryIndicator={onLeave && (() => onLeave(comment.id))} - entryIndicator="comment" - > - - - ) -}) +) ReviewPanelComment.displayName = 'ReviewPanelComment' diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx index 5f65e9fbb0..b79fc85f1c 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx @@ -53,7 +53,7 @@ const ReviewPanelCurrentFile: FC = () => { setHoveredEntry(id) }, []) - const handleEntryLeave = useCallback((id: string) => { + const handleEntryLeave = useCallback(() => { clearTimeout(hoverTimeout.current) hoverTimeout.current = window.setTimeout(() => { setHoveredEntry(null) @@ -333,8 +333,8 @@ const ReviewPanelCurrentFile: FC = () => { top={positions.get(change.id)} aggregate={aggregatedRanges.aggregates.get(change.id)} hovered={hoveredEntry === change.id} - onEnter={handleEntryEnter} - onLeave={handleEntryLeave} + handleEnter={handleEntryEnter} + handleLeave={handleEntryLeave} /> ) )} @@ -348,8 +348,8 @@ const ReviewPanelCurrentFile: FC = () => { comment={comment} top={positions.get(comment.id)} hovered={hoveredEntry === comment.id} - onEnter={handleEntryEnter} - onLeave={handleEntryLeave} + handleEnter={handleEntryEnter} + handleLeave={handleEntryLeave} /> ) )} diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-entry-indicator.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-entry-indicator.tsx new file mode 100644 index 0000000000..ee14dc24f5 --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-entry-indicator.tsx @@ -0,0 +1,27 @@ +import { memo, MouseEventHandler } from 'react' +import MaterialIcon from '@/shared/components/material-icon' + +export const EntryIndicator = memo<{ + handleMouseEnter?: MouseEventHandler + handleMouseLeave?: MouseEventHandler + handleMouseDown?: MouseEventHandler + type: string +}>(function EntryIndicator({ + handleMouseEnter, + handleMouseLeave, + handleMouseDown, + type, +}) { + return ( +
+ +
+ ) +}) diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-entry.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-entry.tsx index 4192dd518e..3576b7b542 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-entry.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-entry.tsx @@ -12,9 +12,9 @@ import { } from '@/features/source-editor/extensions/ranges' import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' import { EditorSelection } from '@codemirror/state' -import MaterialIcon from '@/shared/components/material-icon' import { OFFSET_FOR_ENTRIES_ABOVE } from '../utils/position-items' import useReviewPanelLayout from '../hooks/use-review-panel-layout' +import { EntryIndicator } from './review-panel-entry-indicator' export const ReviewPanelEntry: FC< React.PropsWithChildren<{ @@ -26,8 +26,8 @@ export const ReviewPanelEntry: FC< selectLineOnFocus?: boolean hoverRanges?: boolean disabled?: boolean - onEnterEntryIndicator?: () => void - onLeaveEntryIndicator?: () => void + handleEnter?: () => void + handleLeave?: () => void entryIndicator?: 'comment' | 'edit' }> > = ({ @@ -40,8 +40,8 @@ export const ReviewPanelEntry: FC< docId, hoverRanges = true, disabled, - onEnterEntryIndicator, - onLeaveEntryIndicator, + handleEnter, + handleLeave, entryIndicator, }) => { const state = useCodeMirrorStateContext() @@ -194,19 +194,12 @@ export const ReviewPanelEntry: FC< }} > {entryIndicator && ( -
- -
+ )} {children}
diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-expandable-content.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-expandable-content.tsx index 3850614281..7a8d29f032 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-expandable-content.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-expandable-content.tsx @@ -1,24 +1,24 @@ -import { FC, useCallback, useRef, useState } from 'react' +import { memo, 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<{ +export const ExpandableContent = memo<{ className?: string content: string contentLimit?: number newLineCharsLimit?: number checkNewLines?: boolean inline?: boolean -}> = ({ +}>(function ExpandableContent({ content, className, contentLimit = 50, newLineCharsLimit = 3, checkNewLines = true, inline = false, -}) => { +}) { const { t } = useTranslation() const contentRef = useRef(null) const [isExpanded, setIsExpanded] = useState(false) @@ -83,7 +83,7 @@ export const ExpandableContent: FC<{
) -} +}) function indexOfNthLine(content: string, n: number) { if (n < 1) return null diff --git a/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx b/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx index 16d3228aa6..ebb176c0b5 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx @@ -131,8 +131,8 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({ showGenericConfirmModal({ message: t('confirm_accept_selected_changes', { count: nChanges }), title: t('accept_selected_changes'), - onConfirm: () => { - acceptChanges(...changeIdsInSelection) + onConfirm: async () => { + await acceptChanges(...changeIdsInSelection) }, primaryVariant: 'danger', }) @@ -153,8 +153,8 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({ showGenericConfirmModal({ message: t('confirm_reject_selected_changes', { count: nChanges }), title: t('reject_selected_changes'), - onConfirm: () => { - rejectChanges(...changeIdsInSelection) + onConfirm: async () => { + await rejectChanges(...changeIdsInSelection) }, primaryVariant: 'danger', }) diff --git a/services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx b/services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx index 2e816ccdb2..7066a78bb3 100644 --- a/services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx @@ -32,8 +32,8 @@ export type Ranges = { export const RangesContext = createContext(undefined) type RangesActions = { - acceptChanges: (...ids: string[]) => void - rejectChanges: (...ids: string[]) => void + acceptChanges: (...ids: string[]) => Promise + rejectChanges: (...ids: string[]) => Promise } const buildRanges = (currentDocument: DocumentContainer | null) => { @@ -166,7 +166,7 @@ export const RangesProvider: FC = ({ children }) => { setRanges(buildRanges(currentDocument)) } }, - rejectChanges(...ids: string[]) { + async rejectChanges(...ids: string[]) { if (currentDocument?.ranges) { view.dispatch(rejectChanges(view.state, currentDocument.ranges, ids)) } diff --git a/services/web/frontend/js/features/review-panel-new/utils/position-items.ts b/services/web/frontend/js/features/review-panel-new/utils/position-items.ts index 26fecc19de..87fa92cd80 100644 --- a/services/web/frontend/js/features/review-panel-new/utils/position-items.ts +++ b/services/web/frontend/js/features/review-panel-new/utils/position-items.ts @@ -44,37 +44,39 @@ export const positionItems = debounce( const activeItemTop = getTopPosition(activeItem, activeItemIndex === 0) - activeItem.style.top = `${activeItemTop}px` - activeItem.style.visibility = 'visible' - const focusedItemRect = activeItem.getBoundingClientRect() + const positions: [HTMLElement, number][] = [] + positions.push([activeItem, activeItemTop]) // above the active item let topLimit = activeItemTop for (let i = activeItemIndex - 1; i >= 0; i--) { const item = items[i] - const rect = item.getBoundingClientRect() + const height = item.offsetHeight let top = getTopPosition(item, i === 0) - const bottom = top + rect.height + const bottom = top + height if (bottom > topLimit) { - top = topLimit - rect.height - GAP_BETWEEN_ENTRIES + top = topLimit - height - GAP_BETWEEN_ENTRIES } - item.style.top = `${top}px` - item.style.visibility = 'visible' + positions.push([item, top]) topLimit = top } // below the active item - let bottomLimit = activeItemTop + focusedItemRect.height + let bottomLimit = activeItemTop + activeItem.offsetHeight for (let i = activeItemIndex + 1; i < items.length; i++) { const item = items[i] - const rect = item.getBoundingClientRect() + const height = item.offsetHeight let top = getTopPosition(item, false) if (top < bottomLimit) { top = bottomLimit + GAP_BETWEEN_ENTRIES } + positions.push([item, top]) + bottomLimit = top + height + } + + for (const [item, top] of positions) { item.style.top = `${top}px` item.style.visibility = 'visible' - bottomLimit = top + rect.height } return {