feat: [CODE-350]: Add some PR Comment improvements

This commit is contained in:
“tan-nhu” 2023-05-24 17:54:28 -07:00
parent 7c097a7680
commit 3afec4c78b
26 changed files with 436 additions and 196 deletions

View File

@ -103,8 +103,13 @@
}
}
.btn {
.reviewButton {
&.hide {
visibility: hidden;
}
&.disabled {
pointer-events: none;
opacity: 0.5;
}
}

View File

@ -15,7 +15,8 @@ declare const styles: {
readonly menuItem: string
readonly menuReviewItem: string
readonly reviewIcon: string
readonly btn: string
readonly reviewButton: string
readonly hide: string
readonly disabled: string
}
export default styles

View File

@ -26,6 +26,7 @@ import { DIFF2HTML_CONFIG, ViewStyle } from 'components/DiffViewer/DiffViewerUti
import { NoResultCard } from 'components/NoResultCard/NoResultCard'
import type { TypesPullReq, TypesPullReqActivity } from 'services/code'
import { useShowRequestError } from 'hooks/useShowRequestError'
import { useAppContext } from 'AppContext'
import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
import { ChangesDropdown } from './ChangesDropdown'
import { DiffViewConfiguration } from './DiffViewConfiguration'
@ -88,9 +89,10 @@ export const Changes: React.FC<ChangesProps> = ({
lazy: !pullRequestMetadata?.number
})
const [activities, setActivities] = useState<TypesPullReqActivity[]>()
const showSpinner = useMemo(() => {
return loading || (loadingActivities && !activities)
}, [loading, loadingActivities, activities])
const showSpinner = useMemo(
() => loading || (loadingActivities && !activities),
[loading, loadingActivities, activities]
)
const diffStats = useMemo(
() =>
(diffs || []).reduce(
@ -103,6 +105,16 @@ export const Changes: React.FC<ChangesProps> = ({
),
[diffs]
)
const shouldHideReviewButton = useMemo(
() => readOnly || pullRequestMetadata?.state === 'merged' || pullRequestMetadata?.state === 'closed',
[readOnly, pullRequestMetadata?.state]
)
const { currentUser } = useAppContext()
const isActiveUserPROwner = useMemo(() => {
return (
!!currentUser?.uid && !!pullRequestMetadata?.author?.uid && currentUser?.uid === pullRequestMetadata?.author?.uid
)
}, [currentUser, pullRequestMetadata])
// Optimization to avoid showing unnecessary loading spinner. The trick is to
// show only the spinner when the component is mounted and not when refetching
@ -208,10 +220,11 @@ export const Changes: React.FC<ChangesProps> = ({
<FlexExpander />
<ReviewSplitButton
shouldHide={readOnly || pullRequestMetadata?.state === 'merged'}
shouldHide={shouldHideReviewButton}
repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata}
refreshPr={voidFn(noop)}
disabled={isActiveUserPROwner}
/>
</Layout.Horizontal>
</Container>

View File

@ -32,9 +32,10 @@ interface ReviewSplitButtonProps extends Pick<GitInfoProps, 'repoMetadata'> {
shouldHide: boolean
pullRequestMetadata?: TypesPullReq
refreshPr: () => void
disabled?: boolean
}
const ReviewSplitButton = (props: ReviewSplitButtonProps) => {
const { pullRequestMetadata, repoMetadata, shouldHide, refreshPr } = props
const { pullRequestMetadata, repoMetadata, shouldHide, refreshPr, disabled } = props
const { getString } = useStrings()
const { showError, showSuccess } = useToaster()
@ -75,7 +76,11 @@ const ReviewSplitButton = (props: ReviewSplitButtonProps) => {
.catch(exception => showError(getErrorMessage(exception)))
}, [decisionOption, mutate, showError, showSuccess, getString, refreshPr, pullRequestMetadata?.source_sha])
return (
<Container className={cx(css.btn, { [css.hide]: shouldHide })}>
<Container
className={cx(css.reviewButton, {
[css.hide]: shouldHide,
[css.disabled]: disabled
})}>
<SplitButton
text={decisionOption.title}
disabled={loading}
@ -93,7 +98,7 @@ const ReviewSplitButton = (props: ReviewSplitButtonProps) => {
<Menu.Item
key={option.method}
className={css.menuReviewItem}
disabled={option.disabled}
disabled={disabled || option.disabled}
text={
<Layout.Horizontal>
<Icon

View File

@ -0,0 +1,74 @@
import React, { useMemo, useState } from 'react'
import { useMutate } from 'restful-react'
import { useToaster, Button, ButtonVariation, ButtonSize, ButtonProps, useIsMounted } from '@harness/uicore'
import { useStrings } from 'framework/strings'
import type { GitInfoProps } from 'utils/GitUtils'
import type { TypesPullReqActivity } from 'services/code'
import { useEmitCodeCommentStatus } from 'hooks/useEmitCodeCommentStatus'
import { CodeCommentState, getErrorMessage } from 'utils/Utils'
import type { CommentItem } from '../CommentBox/CommentBox'
interface CodeCommentSecondarySaveButtonProps
extends Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'>,
ButtonProps {
commentItems: CommentItem<TypesPullReqActivity>[]
}
export const CodeCommentSecondarySaveButton: React.FC<CodeCommentSecondarySaveButtonProps> = ({
repoMetadata,
pullRequestMetadata,
commentItems,
onClick,
...props
}) => {
const { getString } = useStrings()
const isMounted = useIsMounted()
const { showError } = useToaster()
const path = useMemo(
() => `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata?.number}/comments`,
[repoMetadata.path, pullRequestMetadata?.number]
)
const { mutate: updateCodeCommentStatus } = useMutate({ verb: 'PUT', path: ({ id }) => `${path}/${id}/status` })
const [resolved, setResolved] = useState(commentItems[0]?.payload?.resolved ? true : false)
const emitCodeCommentStatus = useEmitCodeCommentStatus({
id: commentItems[0]?.payload?.id,
onMatch: status => {
if (isMounted.current) {
setResolved(status === CodeCommentState.RESOLVED)
}
}
})
return (
<Button
text={getString(resolved ? 'replyAndReactivate' : 'replyAndResolve')}
variation={ButtonVariation.TERTIARY}
size={ButtonSize.MEDIUM}
onClick={async () => {
const status = resolved ? CodeCommentState.ACTIVE : CodeCommentState.RESOLVED
const payload = { status }
const id = commentItems[0]?.payload?.id
updateCodeCommentStatus(payload, { pathParams: { id } })
.then(() => {
if (commentItems[0]?.payload) {
if (resolved) {
commentItems[0].payload.resolved = 0
} else {
commentItems[0].payload.resolved = Date.now()
}
}
if (isMounted.current) {
setResolved(!resolved)
}
emitCodeCommentStatus(status)
;(onClick as () => void)()
})
.catch(_exception => {
showError(getErrorMessage(_exception), 0, getString('pr.failedToUpdateCommentStatus'))
})
}}
{...props}
/>
)
}

View File

@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react'
import { useMutate } from 'restful-react'
import { useToaster, Button, ButtonVariation, ButtonSize } from '@harness/uicore'
import { useToaster, Button, ButtonVariation, ButtonSize, useIsMounted } from '@harness/uicore'
import { useStrings } from 'framework/strings'
import type { GitInfoProps } from 'utils/GitUtils'
import type { TypesPullReqActivity } from 'services/code'
@ -19,6 +19,7 @@ export const CodeCommentStatusButton: React.FC<CodeCommentStatusButtonProps> = (
commentItems,
onCommentUpdate
}) => {
const isMounted = useIsMounted()
const { getString } = useStrings()
const { showError } = useToaster()
const path = useMemo(
@ -30,13 +31,24 @@ export const CodeCommentStatusButton: React.FC<CodeCommentStatusButtonProps> = (
const emitCodeCommentStatus = useEmitCodeCommentStatus({
id: commentItems[0]?.payload?.id,
onMatch: status => {
setResolved(status === CodeCommentState.RESOLVED)
if (isMounted.current) {
const isResolved = status === CodeCommentState.RESOLVED
setResolved(isResolved)
if (commentItems[0]?.payload) {
if (isResolved) {
commentItems[0].payload.resolved = Date.now()
} else {
commentItems[0].payload.resolved = 0
}
}
}
}
})
return (
<Button
text={getString(resolved ? 'unresolve' : 'resolve')}
text={getString(resolved ? 'reactivate' : 'resolve')}
variation={ButtonVariation.TERTIARY}
size={ButtonSize.MEDIUM}
onClick={async () => {
@ -47,8 +59,11 @@ export const CodeCommentStatusButton: React.FC<CodeCommentStatusButtonProps> = (
updateCodeCommentStatus(payload, { pathParams: { id } })
.then(() => {
onCommentUpdate()
setResolved(!resolved)
emitCodeCommentStatus(status)
if (isMounted.current) {
setResolved(!resolved)
}
})
.catch(_exception => {
showError(getErrorMessage(_exception), 0, getString('pr.failedToUpdateCommentStatus'))

View File

@ -59,14 +59,21 @@ export const CodeCommentStatusSelect: React.FC<CodeCommentStatusSelectProps> = (
const status = newState.value as CodeCommentState
const payload = { status }
const id = commentItems[0]?.payload?.id
const isActive = status === CodeCommentState.ACTIVE
updateCodeCommentStatus(payload, { pathParams: { id } })
.then(() => {
onCommentUpdate()
setCodeCommentStatus(
status === CodeCommentState.ACTIVE ? codeCommentStatusItems[0] : codeCommentStatusItems[1]
)
setCodeCommentStatus(isActive ? codeCommentStatusItems[0] : codeCommentStatusItems[1])
emitCodeCommentStatus(status)
if (commentItems[0]?.payload) {
if (isActive) {
commentItems[0].payload.resolved = 0
} else {
commentItems[0].payload.resolved = Date.now()
}
}
})
.catch(_exception => {
showError(getErrorMessage(_exception), 0, getString('pr.failedToUpdateCommentStatus'))

View File

@ -35,6 +35,18 @@
display: inline-block;
border-radius: var(--box-radius);
}
.outdated {
background: #fcf4e3 !important;
color: #c05809 !important;
border: 1px solid var(--yellow-300) !important;
padding: 4px 6px !important;
border-radius: 4px !important;
font-weight: 700;
font-size: 10px;
line-height: 15px;
text-transform: uppercase;
}
}
.replyPlaceHolder {

View File

@ -6,6 +6,7 @@ declare const styles: {
readonly box: string
readonly viewer: string
readonly deleted: string
readonly outdated: string
readonly replyPlaceHolder: string
readonly newCommentContainer: string
readonly hasThread: string

View File

@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useResizeDetector } from 'react-resize-detector'
import type { EditorView } from '@codemirror/view'
import { Render, Match, Truthy, Falsy, Else } from 'react-jsx-match'
import { Container, Layout, Avatar, TextInput, Text, Color, FontVariation, FlexExpander } from '@harness/uicore'
import { Container, Layout, Avatar, TextInput, Text, Color, FontVariation, FlexExpander, Button } from '@harness/uicore'
import cx from 'classnames'
import ReactTimeago from 'react-timeago'
import { noop } from 'lodash-es'
@ -21,6 +21,7 @@ export interface CommentItem<T = unknown> {
created: string | number
updated: string | number
deleted: string | number
outdated: boolean
content: string
payload?: T // optional payload for callers to handle on callback calls
}
@ -31,7 +32,7 @@ export enum CommentAction {
REPLY = 'reply',
DELETE = 'delete',
RESOLVE = 'resolve',
UNRESOLVE = 'unresolve'
REACTIVATE = 'reactivate'
}
// Outlets are used to insert additional components into CommentBox
@ -41,7 +42,8 @@ export enum CommentBoxOutletPosition {
TOP_OF_FIRST_COMMENT = 'top_of_first_comment',
BOTTOM_OF_COMMENT_EDITOR = 'bottom_of_comment_editor',
LEFT_OF_OPTIONS_MENU = 'left_of_options_menu',
LEFT_OF_REPLY_PLACEHOLDER = 'left_of_reply_placeholder'
LEFT_OF_REPLY_PLACEHOLDER = 'left_of_reply_placeholder',
BETWEEN_SAVE_AND_CANCEL_BUTTONS = 'between_save_and_cancel_buttons'
}
interface CommentBoxProps<T> {
@ -172,7 +174,7 @@ export const CommentBox = <T = unknown,>({
placeHolder: getString(comments.length ? 'replyHere' : 'leaveAComment'),
tabEdit: getString('write'),
tabPreview: getString('preview'),
save: getString('comment'),
save: getString(comments.length ? 'reply' : 'comment'),
cancel: getString('cancel')
}}
value={markdown}
@ -205,6 +207,11 @@ export const CommentBox = <T = unknown,>({
console.error('handleAction must be implemented...') // eslint-disable-line no-console
}
}}
secondarySaveButton={
comments.length
? (outlets[CommentBoxOutletPosition.BETWEEN_SAVE_AND_CANCEL_BUTTONS] as typeof Button)
: undefined
}
onCancel={_onCancel}
hideCancel={hideCancel}
setDirty={_dirty => {
@ -274,6 +281,12 @@ const CommentsThread = <T = unknown,>({
</>
</Render>
<Render when={commentItem?.outdated}>
<Text inline font={{ variation: FontVariation.SMALL }} className={css.outdated}>
{getString('pr.outdated')}
</Text>
</Render>
<FlexExpander />
<Layout.Horizontal>
<Render when={index === 0 && outlets[CommentBoxOutletPosition.LEFT_OF_OPTIONS_MENU]}>

View File

@ -9,10 +9,12 @@ import {
ButtonVariation,
ButtonSize,
Button,
FlexExpander
FlexExpander,
StringSubstitute
} from '@harness/uicore'
import type { CellProps, Column } from 'react-table'
import { noop, orderBy } from 'lodash-es'
import { Link } from 'react-router-dom'
import { useStrings } from 'framework/strings'
import { useAppContext } from 'AppContext'
import type { TypesCommit } from 'services/code'
@ -21,6 +23,7 @@ import { NoResultCard } from 'components/NoResultCard/NoResultCard'
import { ThreadSection } from 'components/ThreadSection/ThreadSection'
import { formatDate } from 'utils/Utils'
import { CodeIcon, GitInfoProps } from 'utils/GitUtils'
import type { CODERoutes } from 'RouteDefinitions'
import css from './CommitsView.module.scss'
interface CommitsViewProps extends Pick<GitInfoProps, 'repoMetadata'> {
@ -63,7 +66,7 @@ export function CommitsView({
Cell: ({ row }: CellProps<TypesCommit>) => {
return (
<Text color={Color.BLACK} lineClamp={1} className={css.rowText}>
{row.original.message}
{renderPullRequestLinkFromCommitMessage(repoMetadata, routes, row.original.message)}
</Text>
)
}
@ -85,7 +88,7 @@ export function CommitsView({
}
}
],
[repoMetadata.path, routes]
[repoMetadata, routes]
)
const commitsGroupedByDate: Record<string, TypesCommit[]> = useMemo(
() =>
@ -142,3 +145,36 @@ export function CommitsView({
</Container>
)
}
function renderPullRequestLinkFromCommitMessage(
repoMetadata: GitInfoProps['repoMetadata'],
routes: CODERoutes,
commitMessage = ''
) {
let message: string | JSX.Element = commitMessage
const match = message.match(/\(#\d+\)$/)
if (match?.length) {
message = message.replace(match[0], '({URL})')
const pullRequestId = match[0].replace('(#', '').replace(')', '')
message = (
<StringSubstitute
str={message}
vars={{
URL: (
<Link
to={routes.toCODEPullRequest({
repoPath: repoMetadata.path as string,
pullRequestId
})}>
#{pullRequestId}
</Link>
)
}}
/>
)
}
return message
}

View File

@ -11,7 +11,8 @@ import {
Layout,
Text,
ButtonSize,
useToaster
useToaster,
ButtonProps
} from '@harness/uicore'
import cx from 'classnames'
import { Render } from 'react-jsx-match'
@ -30,6 +31,7 @@ import { CopyButton } from 'components/CopyButton/CopyButton'
import { AppWrapper } from 'App'
import { NavigationCheck } from 'components/NavigationCheck/NavigationCheck'
import { CodeCommentStatusButton } from 'components/CodeCommentStatusButton/CodeCommentStatusButton'
import { CodeCommentSecondarySaveButton } from 'components/CodeCommentSecondarySaveButton/CodeCommentSecondarySaveButton'
import { CodeCommentStatusSelect } from 'components/CodeCommentStatusSelect/CodeCommentStatusSelect'
import {
activitiesToDiffCommentItems,
@ -93,7 +95,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
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 [comments, setComments] = useState<DiffCommentItem<TypesPullReqActivity>[]>(activitiesToDiffCommentItems(diff))
const [comments, setComments] = useState<DiffCommentItem<TypesPullReqActivity>[]>([])
const [dirty, setDirty] = useState(false)
const commentsRef = useRef<DiffCommentItem<TypesPullReqActivity>[]>(comments)
const setContainerRef = useCallback(
@ -130,6 +132,19 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
[diffRenderer, renderCustomContent]
)
useEffect(() => {
// For some unknown reason, comments is [] when we switch to Changes tab very quickly sometimes,
// but diff is not empty, and activitiesToDiffCommentItems(diff) is not []. So assigning
// comments = activitiesToDiffCommentItems(diff) from the useState() is not enough.
if (diff) {
const _comments = activitiesToDiffCommentItems(diff)
if (_comments.length > 0 && !comments.length) {
setComments(_comments)
}
}
}, [diff, comments])
useEffect(
function createDiffRenderer() {
if (inView && !diffRenderer) {
@ -231,7 +246,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
)
useEffect(
function renderAnnotatations() {
function renderCodeComments() {
if (readOnly) {
return
}
@ -274,6 +289,28 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
rowElement.after(commentRowElement)
const element = commentRowElement.firstElementChild as HTMLTableCellElement
const resetCommentState = (ignoreCurrentComment = true) => {
// Clean up CommentBox rendering and reset states bound to lineInfo
ReactDOM.unmountComponentAtNode(element as HTMLDivElement)
commentRowElement.parentElement?.removeChild(commentRowElement)
lineInfo.oppositeRowElement?.parentElement?.removeChild(
lineInfo.oppositeRowElement?.nextElementSibling as Element
)
delete lineInfo.rowElement.dataset.annotated
setTimeout(
() =>
setComments(
commentsRef.current.filter(item => {
if (ignoreCurrentComment) {
return item !== comment
}
return true
})
),
0
)
}
// Note: CommentBox is rendered as an independent React component
// everything passed to it must be either values, or refs. If you
@ -284,7 +321,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
<AppWrapper>
<CommentBox
commentItems={comment.commentItems}
initialContent={getInitialCommentContentFromSelection(diff)}
initialContent={''}
width={isSideBySide ? 'calc(100vw / 2 - 163px)' : undefined} // TODO: Re-calcualte for standalone version
onHeightChange={boxHeight => {
if (comment.height !== boxHeight) {
@ -292,16 +329,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
setTimeout(() => setComments([...commentsRef.current]), 0)
}
}}
onCancel={() => {
// Clean up CommentBox rendering and reset states bound to lineInfo
ReactDOM.unmountComponentAtNode(element as HTMLDivElement)
commentRowElement.parentElement?.removeChild(commentRowElement)
lineInfo.oppositeRowElement?.parentElement?.removeChild(
lineInfo.oppositeRowElement?.nextElementSibling as Element
)
delete lineInfo.rowElement.dataset.annotated
setTimeout(() => setComments(commentsRef.current.filter(item => item !== comment)), 0)
}}
onCancel={resetCommentState}
setDirty={setDirty}
currentUserName={currentUser.display_name}
handleAction={async (action, value, commentItem) => {
@ -325,6 +353,9 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
await saveComment(payload)
.then((newComment: TypesPullReqActivity) => {
updatedItem = activityToCommentItem(newComment)
diff.fileActivities?.push(newComment)
comment.commentItems.push(updatedItem)
resetCommentState(false)
})
.catch(exception => {
result = false
@ -334,24 +365,19 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
}
case CommentAction.REPLY: {
const parentComment = diff.fileActivities?.find(
activity => diff.filePath === activity.code_comment?.path
)
if (parentComment) {
await saveComment({
type: CommentType.CODE_COMMENT,
text: value,
parent_id: Number(parentComment.id as number)
await saveComment({
type: CommentType.CODE_COMMENT,
text: value,
parent_id: Number(commentItem?.payload?.id as number)
})
.then(newComment => {
updatedItem = activityToCommentItem(newComment)
diff.fileActivities?.push(newComment)
})
.catch(exception => {
result = false
showError(getErrorMessage(exception), 0)
})
.then(newComment => {
updatedItem = activityToCommentItem(newComment)
})
.catch(exception => {
result = false
showError(getErrorMessage(exception), 0)
})
}
break
}
@ -408,6 +434,14 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
onCommentUpdate={onCommentUpdate}
commentItems={comment.commentItems}
/>
),
[CommentBoxOutletPosition.BETWEEN_SAVE_AND_CANCEL_BUTTONS]: (props: ButtonProps) => (
<CodeCommentSecondarySaveButton
repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata as TypesPullReq}
commentItems={comment.commentItems}
{...props}
/>
)
}}
autoFocusAndPositioning
@ -541,7 +575,3 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
</Container>
)
}
function getInitialCommentContentFromSelection(_diff: DiffFileEntry) {
return ''
}

View File

@ -196,6 +196,7 @@ export const activityToCommentItem = (activity: TypesPullReqActivity): CommentIt
created: activity.created as number,
updated: activity.edited as number,
deleted: activity.deleted as number,
outdated: !!activity.code_comment?.outdated,
content: (activity.text || (activity.payload as Unknown)?.Message) as string,
payload: activity
})

View File

@ -53,6 +53,7 @@ interface MarkdownEditorWithPreviewProps {
editorHeight?: string
noBorder?: boolean
viewRef?: React.MutableRefObject<EditorView | undefined>
secondarySaveButton?: typeof Button
// When set to true, the editor will be scrolled to center of screen
// and cursor is set to the end of the document
@ -71,7 +72,8 @@ export function MarkdownEditorWithPreview({
editorHeight,
noBorder,
viewRef: viewRefProp,
autoFocusAndPositioning
autoFocusAndPositioning,
secondarySaveButton: SecondarySaveButton
}: MarkdownEditorWithPreviewProps) {
const [selectedTab, setSelectedTab] = useState(MarkdownEditorTab.WRITE)
const viewRef = useRef<EditorView>()
@ -281,6 +283,12 @@ export function MarkdownEditorWithPreview({
onClick={() => onSave?.(viewRef.current?.state.doc.toString() || '')}
text={i18n.save}
/>
{SecondarySaveButton && (
<SecondarySaveButton
disabled={!dirty}
onClick={async () => await onSave?.(viewRef.current?.state.doc.toString() || '')}
/>
)}
{!hideCancel && <Button variation={ButtonVariation.TERTIARY} onClick={onCancel} text={i18n.cancel} />}
</Layout.Horizontal>
</Container>

View File

@ -222,8 +222,10 @@ export interface StringsMap {
'pr.modalTitle': string
'pr.notMergeableWithUnresolvedComment': string
'pr.openForReview': string
'pr.outdated': string
'pr.prBranchPushInfo': string
'pr.prCanBeMerged': string
'pr.prClosed': string
'pr.prMerged': string
'pr.prMergedInfo': string
'pr.prReviewSubmit': string
@ -255,12 +257,16 @@ export interface StringsMap {
pullRequestEmpty: string
pullRequests: string
quote: string
reactivate: string
readMe: string
refresh: string
reject: string
rejected: string
remove: string
renameFile: string
reply: string
replyAndReactivate: string
replyAndResolve: string
replyHere: string
repoCloneHeader: string
repoCloneLabel: string
@ -284,6 +290,7 @@ export interface StringsMap {
resetZoom: string
resolve: string
resolved: string
resolvedComments: string
reviewers: string
samplePayloadUrl: string
save: string
@ -306,7 +313,7 @@ export interface StringsMap {
tags: string
title: string
tooltipRepoEdit: string
unresolve: string
unrsolvedComment: string
'unsavedChanges.leave': string
'unsavedChanges.message': string
'unsavedChanges.stay': string

View File

@ -2,29 +2,29 @@ import { useCallback, useEffect } from 'react'
import type { CodeCommentState } from 'utils/Utils'
const PR_COMMENT_STATUS_CHANGED_EVENT = 'PR_COMMENT_STATUS_CHANGED_EVENT'
export const PULL_REQUEST_ALL_COMMENTS_ID = -99999
interface UseEmitCodeCommentStatusProps {
id?: number
onMatch: (status: CodeCommentState) => void
onMatch: (status: CodeCommentState, id: number) => void
}
export function useEmitCodeCommentStatus({ id, onMatch }: UseEmitCodeCommentStatusProps) {
const callback = useCallback(
event => {
if (id && event.detail.id === id) {
onMatch(event.detail.status)
if ((id && event.detail.id === id) || id === PULL_REQUEST_ALL_COMMENTS_ID) {
onMatch(event.detail.status, event.detail.id)
}
},
[id, onMatch]
)
const updateStatus = useCallback(
const emitCodeCommentStatus = useCallback(
(status: CodeCommentState) => {
const event = new CustomEvent(PR_COMMENT_STATUS_CHANGED_EVENT, { detail: { id, status } })
document.dispatchEvent(event)
},
[id]
)
useEffect(() => {
document.addEventListener(PR_COMMENT_STATUS_CHANGED_EVENT, callback)
@ -33,5 +33,5 @@ export function useEmitCodeCommentStatus({ id, onMatch }: UseEmitCodeCommentStat
}
}, [callback])
return updateStatus
return emitCodeCommentStatus
}

View File

@ -3,7 +3,8 @@ import { useCallback, useState } from 'react'
export enum UserPreference {
DIFF_VIEW_STYLE = 'DIFF_VIEW_STYLE',
DIFF_LINE_BREAKS = 'DIFF_LINE_BREAKS',
PULL_REQUESTS_FILTER_SELECTED_OPTIONS = 'PULL_REQUESTS_FILTER_SELECTED_OPTIONS'
PULL_REQUESTS_FILTER_SELECTED_OPTIONS = 'PULL_REQUESTS_FILTER_SELECTED_OPTIONS',
PULL_REQUEST_MERGE_STRATEGY = 'PULL_REQUEST_MERGE_STRATEGY'
}
export function useUserPreference<T = string>(key: UserPreference, defaultValue: T): [T, (val: T) => void] {

View File

@ -193,6 +193,7 @@ pr:
failedToDeleteComment: Failed to delete comment. Please try again.
failedToUpdateCommentStatus: Failed to update comment status. Please try again.
prMerged: This Pull Request was merged
prClosed: This Pull Request was closed.
reviewSubmitted: Review submitted.
prReviewSubmit: '{user} {state|approved:approved, rejected:rejected,changereq:requested to, reviewed} the pull request. {time}'
prMergedInfo: '{user} merged branch {source} into {target} {time}.'
@ -218,6 +219,7 @@ pr:
closeDesc: Close this pull request. You can still re-open the request after closing.
forceMergeWithUnresolvedComment: This pull request has {unrsolvedComment} unresolved {unrsolvedComment|1:comment,comments}. As an administrator, you can still merge it.\nAre you sure you want to force merge it?
notMergeableWithUnresolvedComment: This pull request has {unrsolvedComment} unresolved {unrsolvedComment|1:comment,comments}. Please resolve them before merging.
outdated: Outdated
prState:
draftHeading: This pull request is still a work in progress
draftDesc: Draft pull requests cannot be merged.
@ -238,6 +240,9 @@ viewed: Viewed
comment: Comment
addComment: Add comment
replyHere: Reply here...
reply: Reply
replyAndResolve: Reply & Resolve
replyAndReactivate: Reply & Reactivate
leaveAComment: Leave a comment...
lineBreaks: Line Breaks
quote: Quote
@ -352,6 +357,8 @@ showEverything: Show everything
allComments: All comments
whatsNew: What's new
myComments: My comments/replies
resolvedComments: Resolved comments
unrsolvedComment: Unresolved comment
resetZoom: Reset Zoom
zoomIn: Zoom In
zoomOut: Zoom Out
@ -367,7 +374,7 @@ repoUpdate: Repository Updated
deleteRepoText: Are you sure you want to delete the repository '{REPONAME}'?
deleteRepoTitle: Delete the repository
resolve: Resolve
unresolve: Unresolve
reactivate: Reactivate
generateCloneCred: + Generate Clone Credential
generateCloneText: 'Please generate clone credential if its your first time'
getMyCloneTitle: Get My Clone Credential

View File

@ -15,7 +15,7 @@ interface CodeCommentHeaderProps {
}
export const CodeCommentHeader: React.FC<CodeCommentHeaderProps> = ({ commentItems, threadId }) => {
const _isCodeComment = isCodeComment(commentItems)
const _isCodeComment = isCodeComment(commentItems) && !commentItems[0].deleted
const id = `code-comment-snapshot-${threadId}`
useEffect(() => {

View File

@ -8,7 +8,7 @@ import { useAppContext } from 'AppContext'
import type { TypesPullReqActivity } from 'services/code'
import { CommentAction, CommentBox, CommentBoxOutletPosition, CommentItem } from 'components/CommentBox/CommentBox'
import { useConfirmAct } from 'hooks/useConfirmAction'
import { getErrorMessage, orderSortDate, dayAgoInMS, ButtonRoleProps } from 'utils/Utils'
import { getErrorMessage, orderSortDate, ButtonRoleProps } from 'utils/Utils'
import { activityToCommentItem } from 'components/DiffViewer/DiffViewerUtils'
import { NavigationCheck } from 'components/NavigationCheck/NavigationCheck'
import { ThreadSection } from 'components/ThreadSection/ThreadSection'
@ -28,13 +28,6 @@ export interface ConversationProps extends Pick<GitInfoProps, 'repoMetadata' | '
prHasChanged?: boolean
}
export enum prSortState {
SHOW_EVERYTHING = 'showEverything',
ALL_COMMENTS = 'allComments',
WHATS_NEW = 'whatsNew',
MY_COMMENTS = 'myComments'
}
export const Conversation: React.FC<ConversationProps> = ({
repoMetadata,
pullRequestMetadata,
@ -57,10 +50,8 @@ export const Conversation: React.FC<ConversationProps> = ({
})
const { showError } = useToaster()
const [dateOrderSort, setDateOrderSort] = useState<boolean | 'desc' | 'asc'>(orderSortDate.ASC)
const [prShowState, setPrShowState] = useState<SelectOption>({
label: `Show Everything `,
value: 'showEverything'
})
const activityFilters = useActivityFilters()
const [activityFilter, setActivityFilter] = useState<SelectOption>(activityFilters[0] as SelectOption)
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
@ -86,61 +77,30 @@ export const Conversation: React.FC<ConversationProps> = ({
blocks.push(parentActivity.map(activityToCommentItem))
})
// Group title-change events into one single block
// Disabled for now, @see https://harness.atlassian.net/browse/SCM-79
// const titleChangeItems =
// blocks.filter(
// _activities => isSystemComment(_activities) && _activities[0].payload?.type === CommentType.TITLE_CHANGE
// ) || []
switch (activityFilter.value) {
case PRCommentFilterType.ALL_COMMENTS:
return blocks.filter(_activities => !isSystemComment(_activities))
// titleChangeItems.forEach((value, index) => {
// if (index > 0) {
// titleChangeItems[0].push(...value)
// }
// })
// titleChangeItems.shift()
// return blocks.filter(_activities => !titleChangeItems.includes (_activities))
case PRCommentFilterType.RESOLVED_COMMENTS:
return blocks.filter(_activities => isCodeComment(_activities) && _activities[0].payload?.resolved)
if (prShowState.value === prSortState.ALL_COMMENTS) {
const allCommentBlock = blocks.filter(_activities => !isSystemComment(_activities))
return allCommentBlock
}
case PRCommentFilterType.UNRESOLVED_COMMENTS:
return blocks.filter(_activities => isCodeComment(_activities) && !_activities[0].payload?.resolved)
if (prShowState.value === prSortState.WHATS_NEW) {
// get current time in seconds and subtract it by a day and see if comments are newer than a day
const lastComment = blocks[blocks.length - 1]
const lastCommentTime = lastComment[lastComment.length - 1].payload?.edited
if (lastCommentTime !== undefined) {
const currentTime = lastCommentTime - dayAgoInMS
const newestBlock = blocks.filter(_activities => {
const mostRecentComment = _activities[_activities.length - 1]
if (mostRecentComment?.payload?.edited !== undefined) {
return mostRecentComment?.payload?.edited > currentTime
}
case PRCommentFilterType.MY_COMMENTS: {
const allCommentBlock = blocks.filter(_activities => !isSystemComment(_activities))
const userCommentsOnly = allCommentBlock.filter(_activities => {
const userCommentReply = _activities.filter(
authorIsUser => authorIsUser.payload?.author?.uid === currentUser.uid
)
return userCommentReply.length !== 0
})
return newestBlock
return userCommentsOnly
}
}
// show only comments made by user or replies in threads by user
if (prShowState.value === prSortState.MY_COMMENTS) {
const allCommentBlock = blocks.filter(_activities => !isSystemComment(_activities))
const userCommentsOnly = allCommentBlock.filter(_activities => {
const userCommentReply = _activities.filter(
authorIsUser => authorIsUser.payload?.author?.uid === currentUser.uid
)
if (userCommentReply.length !== 0) {
return true
} else {
return false
}
})
return userCommentsOnly
}
return blocks
}, [activities, dateOrderSort, prShowState, currentUser.uid])
}, [activities, dateOrderSort, activityFilter, currentUser.uid])
const path = useMemo(
() => `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata.number}/comments`,
[repoMetadata.path, pullRequestMetadata.number]
@ -183,28 +143,11 @@ export const Conversation: React.FC<ConversationProps> = ({
<Layout.Horizontal className={css.sortContainer} padding={{ top: 'xxlarge', bottom: 'medium' }}>
<Container>
<Select
items={[
{
label: getString('showEverything'),
value: prSortState.SHOW_EVERYTHING
},
{
label: getString('allComments'),
value: prSortState.ALL_COMMENTS
},
{
label: getString('whatsNew'),
value: prSortState.WHATS_NEW
},
{
label: getString('myComments'),
value: prSortState.MY_COMMENTS
}
]}
value={prShowState}
items={activityFilters}
value={activityFilter}
className={css.selectButton}
onChange={newState => {
setPrShowState(newState)
setActivityFilter(newState)
refetchActivities()
}}
/>
@ -387,3 +330,41 @@ export const Conversation: React.FC<ConversationProps> = ({
</PullRequestTabContentWrapper>
)
}
export enum PRCommentFilterType {
SHOW_EVERYTHING = 'showEverything',
ALL_COMMENTS = 'allComments',
MY_COMMENTS = 'myComments',
RESOLVED_COMMENTS = 'resolvedComments',
UNRESOLVED_COMMENTS = 'unresolvedComments'
}
function useActivityFilters() {
const { getString } = useStrings()
return useMemo(
() => [
{
label: getString('showEverything'),
value: PRCommentFilterType.SHOW_EVERYTHING
},
{
label: getString('allComments'),
value: PRCommentFilterType.ALL_COMMENTS
},
{
label: getString('myComments'),
value: PRCommentFilterType.MY_COMMENTS
},
{
label: getString('unrsolvedComment'),
value: PRCommentFilterType.UNRESOLVED_COMMENTS
},
{
label: getString('resolvedComments'),
value: PRCommentFilterType.RESOLVED_COMMENTS
}
],
[getString]
)
}

View File

@ -16,6 +16,14 @@
background-color: var(--red-50) !important;
}
&.closed {
background-color: var(--grey-100) !important;
}
&.draft {
background-color: var(--orange-100) !important;
}
&.unchecked {
background-color: #fcf4e3 !important; // Note: No UICore color variable for this background
}
@ -48,12 +56,16 @@
line-height: 20px !important;
color: var(--green-800) !important;
&.closed {
color: var(--grey-600) !important;
}
&.merged {
color: var(--purple-700) !important;
}
&.draft {
color: var(--grey-700) !important;
color: var(--orange-900) !important;
}
&.unmergeable {
@ -106,6 +118,11 @@
--background-color-active: var(--grey-100) !important;
}
&.disabled {
pointer-events: none;
opacity: 0.5;
}
a,
button {
--background-color: var(--green-800) !important;

View File

@ -4,19 +4,21 @@ declare const styles: {
readonly main: string
readonly merged: string
readonly error: string
readonly closed: string
readonly draft: string
readonly unchecked: string
readonly layout: string
readonly secondaryButton: string
readonly btn: string
readonly heading: string
readonly sub: string
readonly draft: string
readonly unmergeable: string
readonly popover: string
readonly menuItem: string
readonly menuReviewItem: string
readonly btnWrapper: string
readonly hasError: string
readonly disabled: string
readonly mergeContainer: string
}
export default styles

View File

@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react'
import React, { useMemo } from 'react'
import {
Button,
ButtonVariation,
@ -32,6 +32,7 @@ import { useConfirmAct } from 'hooks/useConfirmAction'
import { useAppContext } from 'AppContext'
import { Images } from 'images'
import { getErrorMessage, MergeCheckStatus, permissionProps } from 'utils/Utils'
import { UserPreference, useUserPreference } from 'hooks/useUserPreference'
import ReviewSplitButton from 'components/Changes/ReviewSplitButton/ReviewSplitButton'
import css from './PullRequestActionsBox.module.scss'
@ -53,6 +54,7 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
}) => {
const { getString } = useStrings()
const { showError } = useToaster()
const { currentUser } = useAppContext()
const { hooks, standalone } = useAppContext()
const space = useGetSpaceParam()
const { mutate: mergePR, loading } = useMutate({
@ -67,9 +69,11 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
() => pullRequestMetadata.merge_check_status === MergeCheckStatus.MERGEABLE,
[pullRequestMetadata]
)
const isClosed = pullRequestMetadata.state === PullRequestState.CLOSED
const isOpen = pullRequestMetadata.state === PullRequestState.OPEN
const unchecked = useMemo(
() => pullRequestMetadata.merge_check_status === MergeCheckStatus.UNCHECKED,
[pullRequestMetadata]
() => pullRequestMetadata.merge_check_status === MergeCheckStatus.UNCHECKED && !isClosed,
[pullRequestMetadata, isClosed]
)
const isDraft = pullRequestMetadata.is_draft
const mergeOptions: PRMergeOption[] = [
@ -77,18 +81,19 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
method: 'squash',
title: getString('pr.mergeOptions.squashAndMerge'),
desc: getString('pr.mergeOptions.squashAndMergeDesc'),
disabled: false
disabled: mergeable === false
},
{
method: 'merge',
title: getString('pr.mergeOptions.createMergeCommit'),
desc: getString('pr.mergeOptions.createMergeCommitDesc')
desc: getString('pr.mergeOptions.createMergeCommitDesc'),
disabled: mergeable === false
},
{
method: 'rebase',
title: getString('pr.mergeOptions.rebaseAndMerge'),
desc: getString('pr.mergeOptions.rebaseAndMergeDesc'),
disabled: false
disabled: mergeable === false
},
{
method: 'close',
@ -106,7 +111,11 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
},
[space]
)
const [mergeOption, setMergeOption] = useState<PRMergeOption>(mergeOptions[1])
const [mergeOption, setMergeOption] = useUserPreference<PRMergeOption>(
UserPreference.PULL_REQUEST_MERGE_STRATEGY,
mergeOptions[mergeable === false ? 3 : 1]
)
const permPushResult = hooks?.usePermissionTranslate?.(
{
resource: {
@ -116,6 +125,11 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
},
[space]
)
const isActiveUserPROwner = useMemo(() => {
return (
!!currentUser?.uid && !!pullRequestMetadata?.author?.uid && currentUser?.uid === pullRequestMetadata?.author?.uid
)
}, [currentUser, pullRequestMetadata])
if (pullRequestMetadata.state === PullRequestFilterOption.MERGED) {
return <MergeInfo pullRequestMetadata={pullRequestMetadata} />
@ -124,31 +138,46 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
return (
<Container
className={cx(css.main, {
[css.error]: mergeable === false && !unchecked,
[css.unchecked]: unchecked
[css.error]: mergeable === false && !unchecked && !isClosed && !isDraft,
[css.unchecked]: unchecked,
[css.closed]: isClosed,
[css.draft]: isDraft
})}>
<Layout.Vertical spacing="xlarge">
<Container>
<Layout.Horizontal spacing="small" flex={{ alignItems: 'center' }} className={css.layout}>
{(unchecked && <img src={Images.PrUnchecked} width={20} height={20} />) || (
<Icon
name={isDraft ? CodeIcon.Draft : mergeable === false ? 'warning-sign' : 'tick-circle'}
name={
isDraft ? CodeIcon.Draft : isClosed ? 'issue' : mergeable === false ? 'warning-sign' : 'tick-circle'
}
size={20}
color={isDraft ? Color.ORANGE_900 : mergeable === false ? Color.RED_500 : Color.GREEN_700}
color={
isDraft
? Color.ORANGE_900
: isClosed
? Color.GREY_500
: mergeable === false
? Color.RED_500
: Color.GREEN_700
}
/>
)}
<Text
className={cx(css.sub, {
[css.unchecked]: unchecked,
[css.draft]: isDraft,
[css.unmergeable]: mergeable === false
[css.closed]: isClosed,
[css.unmergeable]: mergeable === false && isOpen
})}>
{getString(
isDraft
? 'prState.draftHeading'
: isClosed
? 'pr.prClosed'
: unchecked
? 'pr.checkingToMerge'
: mergeable === false
: mergeable === false && isOpen
? 'pr.cantBeMerged'
: 'pr.branchHasNoConflicts'
)}
@ -196,6 +225,7 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata}
refreshPr={onPRStateChanged}
disabled={isActiveUserPROwner}
/>
<Container
inline
@ -265,29 +295,6 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
.catch(exception => showError(getErrorMessage(exception)))
}
}}>
{/* TODO: These two items are used for creating a PR
<Menu.Item
className={css.menuItem}
text={
<>
<BIcon icon="blank" />
<strong>Create pull request</strong>
<p>Open a pull request that is ready for review</p>
<p>Automatically request reviews from code owners</p>
</>
}
/>
<Menu.Item
className={css.menuItem}
text={
<>
<BIcon icon="blank" />
<strong>Create draft pull request</strong>
<p>Does not request code reviews and cannot be merged</p>
<p>Cannot be merged until marked ready for review</p>
</>
}
/> */}
{mergeOptions.map(option => {
return (
<Menu.Item

View File

@ -23,7 +23,7 @@ import { useAppContext } from 'AppContext'
import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
import { useStrings } from 'framework/strings'
import { RepositoryPageHeader } from 'components/RepositoryPageHeader/RepositoryPageHeader'
import { voidFn, getErrorMessage, MergeCheckStatus } from 'utils/Utils'
import { voidFn, getErrorMessage } from 'utils/Utils'
import { CodeIcon, GitInfoProps } from 'utils/GitUtils'
import type { TypesPullReq, TypesPullReqStats, TypesRepository } from 'services/code'
import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
@ -77,9 +77,6 @@ export default function PullRequest() {
}
return false
}, [prData?.stats, stats])
const mergeable = useMemo(() => {
return prData?.merge_check_status === MergeCheckStatus.MERGEABLE
}, [prData])
useEffect(
function setStatsIfNotSet() {
@ -105,14 +102,14 @@ export default function PullRequest() {
const fn = () => {
if (repoMetadata) {
refetchPullRequest().then(() => {
interval = window.setTimeout(fn, mergeable ? PR_POLLING_INTERVAL : PR_POLLING_INTERVAL_WHEN_NOT_MERGEABLE)
interval = window.setTimeout(fn, PR_POLLING_INTERVAL)
})
}
}
let interval = window.setTimeout(fn, mergeable ? PR_POLLING_INTERVAL : PR_POLLING_INTERVAL_WHEN_NOT_MERGEABLE)
let interval = window.setTimeout(fn, PR_POLLING_INTERVAL)
return () => window.clearTimeout(interval)
}, [repoMetadata, refetchPullRequest, path, mergeable])
}, [repoMetadata, refetchPullRequest, path])
const activeTab = useMemo(
() =>
@ -359,5 +356,4 @@ enum PullRequestSection {
CHECKS = 'checks'
}
const PR_POLLING_INTERVAL = 15000
const PR_POLLING_INTERVAL_WHEN_NOT_MERGEABLE = 5000
const PR_POLLING_INTERVAL = 10000

View File

@ -90,7 +90,7 @@ export function FileContent({
title: getString('content'),
panel: (
<Container className={css.fileContent}>
<Layout.Vertical spacing="small">
<Layout.Vertical spacing="small" style={{ maxWidth: '100%' }}>
<LatestCommitForFile
repoMetadata={repoMetadata}
latestCommit={resourceContent.latest_commit}

View File

@ -184,6 +184,7 @@ const GeneralSettingsContent = (props: GeneralSettingsProps) => {
{getString('dangerDeleteRepo')}
</Text>
<Button
disabled={true} // TODO: Disable until backend has soft delete
intent={Intent.DANGER}
onClick={() => {
confirmDeleteBranch()