import React, { useEffect, useMemo, useState } from 'react' import { Avatar, Color, Container, FlexExpander, FontVariation, Icon, Layout, StringSubstitute, Text, useToaster } from '@harness/uicore' import cx from 'classnames' import { useGet, useMutate } from 'restful-react' import ReactTimeago from 'react-timeago' import { orderBy } from 'lodash-es' import { Render } from 'react-jsx-match' import { CodeIcon, GitInfoProps } from 'utils/GitUtils' import { MarkdownViewer } from 'components/SourceCodeViewer/SourceCodeViewer' import { useStrings } from 'framework/strings' import { useAppContext } from 'AppContext' import type { TypesPullReqActivity } from 'services/code' import { CommentAction, CommentBox, CommentBoxOutletPosition, CommentItem } from 'components/CommentBox/CommentBox' import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator' import { OptionsMenuButton } from 'components/OptionsMenuButton/OptionsMenuButton' import { MarkdownEditorWithPreview } from 'components/MarkdownEditorWithPreview/MarkdownEditorWithPreview' import { useConfirmAct } from 'hooks/useConfirmAction' import { formatDate, formatTime, getErrorMessage } from 'utils/Utils' import { activityToCommentItem, CommentType, PullRequestCodeCommentPayload } from 'components/DiffViewer/DiffViewerUtils' import { PullRequestTabContentWrapper } from '../PullRequestTabContentWrapper' import { PullRequestStatusInfo } from './PullRequestStatusInfo/PullRequestStatusInfo' import css from './Conversation.module.scss' interface ConversationProps extends Pick { refreshPullRequestMetadata: () => void } export const Conversation: React.FC = ({ repoMetadata, pullRequestMetadata, refreshPullRequestMetadata }) => { const { getString } = useStrings() const { currentUser } = useAppContext() const { data: activities, loading, error, refetch: refetchActivities } = useGet({ path: `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata.number}/activities` }) const { showError } = useToaster() const [newComments, setNewComments] = useState([]) const activityBlocks = useMemo(() => { // Each block may have one or more activities which are grouped into it. For example, one comment block // contains a parent comment and multiple replied comments const blocks: CommentItem[][] = [] if (newComments.length) { blocks.push(orderBy(newComments, 'edited', 'desc').map(activityToCommentItem)) } // Determine all parent activities const parentActivities = orderBy(activities?.filter(activity => !activity.parent_id) || [], 'edited', 'desc').map( _comment => [_comment] ) // Then add their children as follow-up elements (same array) parentActivities?.forEach(parentActivity => { const childActivities = activities?.filter(activity => activity.parent_id === parentActivity[0].id) childActivities?.forEach(childComment => { parentActivity.push(childComment) }) }) parentActivities?.forEach(parentActivity => { blocks.push(parentActivity.map(activityToCommentItem)) }) // Group title-change events into one single block const titleChangeItems = blocks.filter( _activities => isSystemComment(_activities) && _activities[0].payload?.type === CommentType.TITLE_CHANGE ) || [] titleChangeItems.forEach((value, index) => { if (index > 0) { titleChangeItems[0].push(...value) } }) titleChangeItems.shift() return blocks.filter(_activities => !titleChangeItems.includes(_activities)) }, [activities, newComments]) const path = useMemo( () => `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata.number}/comments`, [repoMetadata.path, pullRequestMetadata.number] ) const { mutate: saveComment } = useMutate({ verb: 'POST', path }) const { mutate: updateComment } = useMutate({ verb: 'PATCH', path: ({ id }) => `${path}/${id}` }) const { mutate: deleteComment } = useMutate({ verb: 'DELETE', path: ({ id }) => `${path}/${id}` }) const confirmAct = useConfirmAct() const [commentCreated, setCommentCreated] = useState(false) useAnimateNewCommentBox(commentCreated, setCommentCreated) return ( { refreshPullRequestMetadata() refetchActivities() }} /> {activityBlocks?.map((blocks, index) => { const threadId = blocks[0].payload?.id const commentItems = blocks if (isSystemComment(commentItems)) { return ( ) } return ( { let result = true let updatedItem: CommentItem | undefined = undefined const id = (commentItem as CommentItem)?.payload?.id switch (action) { case CommentAction.DELETE: result = false await confirmAct({ message: getString('deleteCommentConfirm'), action: async () => { await deleteComment({}, { pathParams: { id } }) .then(() => { result = true }) .catch(exception => { result = false showError(getErrorMessage(exception), 0, getString('pr.failedToDeleteComment')) }) } }) break case CommentAction.REPLY: await saveComment({ text: value, parent_id: Number(threadId) }) .then(newComment => { updatedItem = activityToCommentItem(newComment) }) .catch(exception => { result = false showError(getErrorMessage(exception), 0, getString('pr.failedToSaveComment')) }) break case CommentAction.UPDATE: await updateComment({ text: value }, { pathParams: { id } }) .then(newComment => { updatedItem = activityToCommentItem(newComment) }) .catch(exception => { result = false showError(getErrorMessage(exception), 0, getString('pr.failedToSaveComment')) }) break } return [result, updatedItem] }} outlets={{ [CommentBoxOutletPosition.TOP_OF_FIRST_COMMENT]: isCodeComment(commentItems) && ( ) }} /> ) })} { let result = true let updatedItem: CommentItem | undefined = undefined await saveComment({ text: value }) .then((newComment: TypesPullReqActivity) => { updatedItem = activityToCommentItem(newComment) setNewComments([...newComments, newComment]) setCommentCreated(true) }) .catch(exception => { result = false showError(getErrorMessage(exception), 0) }) return [result, updatedItem] }} /> ) } const DescriptionBox: React.FC = ({ repoMetadata, pullRequestMetadata, refreshPullRequestMetadata }) => { const [edit, setEdit] = useState(false) const [updated, setUpdated] = useState(pullRequestMetadata.edited as number) const [originalContent, setOriginalContent] = useState(pullRequestMetadata.description as string) const [content, setContent] = useState(originalContent) const { getString } = useStrings() const { showError } = useToaster() const { mutate } = useMutate({ verb: 'PATCH', path: `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata.number}` }) const name = pullRequestMetadata.author?.display_name return ( {name} setEdit(true) } ]} /> {(edit && ( { mutate({ title: pullRequestMetadata.title, description: value }) .then(() => { setContent(value) setOriginalContent(value) setEdit(false) setUpdated(Date.now()) refreshPullRequestMetadata() }) .catch(exception => showError(getErrorMessage(exception), 0, getString('pr.failedToUpdate'))) }} onCancel={() => { setContent(originalContent) setEdit(false) }} i18n={{ placeHolder: getString('pr.enterDesc'), tabEdit: getString('write'), tabPreview: getString('preview'), save: getString('save'), cancel: getString('cancel') }} maxEditorHeight="400px" /> )) || } ) } function isCodeComment(commentItems: CommentItem[]) { return commentItems[0]?.payload?.payload?.type === CommentType.CODE_COMMENT } interface CodeCommentHeaderProps { commentItems: CommentItem[] } const CodeCommentHeader: React.FC = ({ commentItems }) => { if (isCodeComment(commentItems)) { const payload = commentItems[0]?.payload?.payload as PullRequestCodeCommentPayload return ( {payload?.file_title}
) } return null } function isSystemComment(commentItems: CommentItem[]) { return commentItems[0].payload?.kind === 'system' } interface SystemBoxProps extends Pick { commentItems: CommentItem[] } const SystemBox: React.FC = ({ pullRequestMetadata, commentItems }) => { const { getString } = useStrings() const payload = commentItems[0].payload const type = payload?.type switch (type) { case CommentType.MERGE: { return ( {pullRequestMetadata.merger?.display_name}, source: {pullRequestMetadata.source_branch}, target: {pullRequestMetadata.target_branch} , time: }} /> ) } case CommentType.TITLE_CHANGE: { return ( {payload?.author?.display_name}, old: ( {payload?.payload?.old} ), new: {payload?.payload?.new} }} /> 1}> index > 0) .map( item => `|${item.author}|${item.payload?.payload?.old}|${ item.payload?.payload?.new }|${formatDate(item.updated)} ${formatTime(item.updated)}|` ) ) .join('\n')} /> ) } default: { // eslint-disable-next-line no-console console.warn('Unable to render system type activity', commentItems) return ( {type} ) } } } function useAnimateNewCommentBox( commentCreated: boolean, setCommentCreated: React.Dispatch> ) { useEffect(() => { let timeoutId = 0 if (commentCreated) { timeoutId = window.setTimeout(() => { const box = document.querySelector(`.${css.newCommentCreated}`) box?.scrollIntoView({ behavior: 'smooth', block: 'center' }) timeoutId = window.setTimeout(() => { box?.classList.add(css.clear) timeoutId = window.setTimeout(() => setCommentCreated(false), 2000) }, 5000) }, 300) } return () => { clearTimeout(timeoutId) } }, [commentCreated, setCommentCreated]) }