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 { &.hide {
visibility: hidden; visibility: hidden;
} }
&.disabled {
pointer-events: none;
opacity: 0.5;
}
} }

View File

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

View File

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

View File

@ -32,9 +32,10 @@ interface ReviewSplitButtonProps extends Pick<GitInfoProps, 'repoMetadata'> {
shouldHide: boolean shouldHide: boolean
pullRequestMetadata?: TypesPullReq pullRequestMetadata?: TypesPullReq
refreshPr: () => void refreshPr: () => void
disabled?: boolean
} }
const ReviewSplitButton = (props: ReviewSplitButtonProps) => { const ReviewSplitButton = (props: ReviewSplitButtonProps) => {
const { pullRequestMetadata, repoMetadata, shouldHide, refreshPr } = props const { pullRequestMetadata, repoMetadata, shouldHide, refreshPr, disabled } = props
const { getString } = useStrings() const { getString } = useStrings()
const { showError, showSuccess } = useToaster() const { showError, showSuccess } = useToaster()
@ -75,7 +76,11 @@ const ReviewSplitButton = (props: ReviewSplitButtonProps) => {
.catch(exception => showError(getErrorMessage(exception))) .catch(exception => showError(getErrorMessage(exception)))
}, [decisionOption, mutate, showError, showSuccess, getString, refreshPr, pullRequestMetadata?.source_sha]) }, [decisionOption, mutate, showError, showSuccess, getString, refreshPr, pullRequestMetadata?.source_sha])
return ( return (
<Container className={cx(css.btn, { [css.hide]: shouldHide })}> <Container
className={cx(css.reviewButton, {
[css.hide]: shouldHide,
[css.disabled]: disabled
})}>
<SplitButton <SplitButton
text={decisionOption.title} text={decisionOption.title}
disabled={loading} disabled={loading}
@ -93,7 +98,7 @@ const ReviewSplitButton = (props: ReviewSplitButtonProps) => {
<Menu.Item <Menu.Item
key={option.method} key={option.method}
className={css.menuReviewItem} className={css.menuReviewItem}
disabled={option.disabled} disabled={disabled || option.disabled}
text={ text={
<Layout.Horizontal> <Layout.Horizontal>
<Icon <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 React, { useMemo, useState } from 'react'
import { useMutate } from 'restful-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 { useStrings } from 'framework/strings'
import type { GitInfoProps } from 'utils/GitUtils' import type { GitInfoProps } from 'utils/GitUtils'
import type { TypesPullReqActivity } from 'services/code' import type { TypesPullReqActivity } from 'services/code'
@ -19,6 +19,7 @@ export const CodeCommentStatusButton: React.FC<CodeCommentStatusButtonProps> = (
commentItems, commentItems,
onCommentUpdate onCommentUpdate
}) => { }) => {
const isMounted = useIsMounted()
const { getString } = useStrings() const { getString } = useStrings()
const { showError } = useToaster() const { showError } = useToaster()
const path = useMemo( const path = useMemo(
@ -30,13 +31,24 @@ export const CodeCommentStatusButton: React.FC<CodeCommentStatusButtonProps> = (
const emitCodeCommentStatus = useEmitCodeCommentStatus({ const emitCodeCommentStatus = useEmitCodeCommentStatus({
id: commentItems[0]?.payload?.id, id: commentItems[0]?.payload?.id,
onMatch: status => { 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 ( return (
<Button <Button
text={getString(resolved ? 'unresolve' : 'resolve')} text={getString(resolved ? 'reactivate' : 'resolve')}
variation={ButtonVariation.TERTIARY} variation={ButtonVariation.TERTIARY}
size={ButtonSize.MEDIUM} size={ButtonSize.MEDIUM}
onClick={async () => { onClick={async () => {
@ -47,8 +59,11 @@ export const CodeCommentStatusButton: React.FC<CodeCommentStatusButtonProps> = (
updateCodeCommentStatus(payload, { pathParams: { id } }) updateCodeCommentStatus(payload, { pathParams: { id } })
.then(() => { .then(() => {
onCommentUpdate() onCommentUpdate()
setResolved(!resolved)
emitCodeCommentStatus(status) emitCodeCommentStatus(status)
if (isMounted.current) {
setResolved(!resolved)
}
}) })
.catch(_exception => { .catch(_exception => {
showError(getErrorMessage(_exception), 0, getString('pr.failedToUpdateCommentStatus')) 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 status = newState.value as CodeCommentState
const payload = { status } const payload = { status }
const id = commentItems[0]?.payload?.id const id = commentItems[0]?.payload?.id
const isActive = status === CodeCommentState.ACTIVE
updateCodeCommentStatus(payload, { pathParams: { id } }) updateCodeCommentStatus(payload, { pathParams: { id } })
.then(() => { .then(() => {
onCommentUpdate() onCommentUpdate()
setCodeCommentStatus( setCodeCommentStatus(isActive ? codeCommentStatusItems[0] : codeCommentStatusItems[1])
status === CodeCommentState.ACTIVE ? codeCommentStatusItems[0] : codeCommentStatusItems[1]
)
emitCodeCommentStatus(status) emitCodeCommentStatus(status)
if (commentItems[0]?.payload) {
if (isActive) {
commentItems[0].payload.resolved = 0
} else {
commentItems[0].payload.resolved = Date.now()
}
}
}) })
.catch(_exception => { .catch(_exception => {
showError(getErrorMessage(_exception), 0, getString('pr.failedToUpdateCommentStatus')) showError(getErrorMessage(_exception), 0, getString('pr.failedToUpdateCommentStatus'))

View File

@ -35,6 +35,18 @@
display: inline-block; display: inline-block;
border-radius: var(--box-radius); 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 { .replyPlaceHolder {

View File

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

View File

@ -9,10 +9,12 @@ import {
ButtonVariation, ButtonVariation,
ButtonSize, ButtonSize,
Button, Button,
FlexExpander FlexExpander,
StringSubstitute
} from '@harness/uicore' } from '@harness/uicore'
import type { CellProps, Column } from 'react-table' import type { CellProps, Column } from 'react-table'
import { noop, orderBy } from 'lodash-es' import { noop, orderBy } from 'lodash-es'
import { Link } from 'react-router-dom'
import { useStrings } from 'framework/strings' import { useStrings } from 'framework/strings'
import { useAppContext } from 'AppContext' import { useAppContext } from 'AppContext'
import type { TypesCommit } from 'services/code' import type { TypesCommit } from 'services/code'
@ -21,6 +23,7 @@ import { NoResultCard } from 'components/NoResultCard/NoResultCard'
import { ThreadSection } from 'components/ThreadSection/ThreadSection' import { ThreadSection } from 'components/ThreadSection/ThreadSection'
import { formatDate } from 'utils/Utils' import { formatDate } from 'utils/Utils'
import { CodeIcon, GitInfoProps } from 'utils/GitUtils' import { CodeIcon, GitInfoProps } from 'utils/GitUtils'
import type { CODERoutes } from 'RouteDefinitions'
import css from './CommitsView.module.scss' import css from './CommitsView.module.scss'
interface CommitsViewProps extends Pick<GitInfoProps, 'repoMetadata'> { interface CommitsViewProps extends Pick<GitInfoProps, 'repoMetadata'> {
@ -63,7 +66,7 @@ export function CommitsView({
Cell: ({ row }: CellProps<TypesCommit>) => { Cell: ({ row }: CellProps<TypesCommit>) => {
return ( return (
<Text color={Color.BLACK} lineClamp={1} className={css.rowText}> <Text color={Color.BLACK} lineClamp={1} className={css.rowText}>
{row.original.message} {renderPullRequestLinkFromCommitMessage(repoMetadata, routes, row.original.message)}
</Text> </Text>
) )
} }
@ -85,7 +88,7 @@ export function CommitsView({
} }
} }
], ],
[repoMetadata.path, routes] [repoMetadata, routes]
) )
const commitsGroupedByDate: Record<string, TypesCommit[]> = useMemo( const commitsGroupedByDate: Record<string, TypesCommit[]> = useMemo(
() => () =>
@ -142,3 +145,36 @@ export function CommitsView({
</Container> </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, Layout,
Text, Text,
ButtonSize, ButtonSize,
useToaster useToaster,
ButtonProps
} from '@harness/uicore' } from '@harness/uicore'
import cx from 'classnames' import cx from 'classnames'
import { Render } from 'react-jsx-match' import { Render } from 'react-jsx-match'
@ -30,6 +31,7 @@ import { CopyButton } from 'components/CopyButton/CopyButton'
import { AppWrapper } from 'App' import { AppWrapper } from 'App'
import { NavigationCheck } from 'components/NavigationCheck/NavigationCheck' import { NavigationCheck } from 'components/NavigationCheck/NavigationCheck'
import { CodeCommentStatusButton } from 'components/CodeCommentStatusButton/CodeCommentStatusButton' import { CodeCommentStatusButton } from 'components/CodeCommentStatusButton/CodeCommentStatusButton'
import { CodeCommentSecondarySaveButton } from 'components/CodeCommentSecondarySaveButton/CodeCommentSecondarySaveButton'
import { CodeCommentStatusSelect } from 'components/CodeCommentStatusSelect/CodeCommentStatusSelect' import { CodeCommentStatusSelect } from 'components/CodeCommentStatusSelect/CodeCommentStatusSelect'
import { import {
activitiesToDiffCommentItems, activitiesToDiffCommentItems,
@ -93,7 +95,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
const { mutate: saveComment } = useMutate({ verb: 'POST', path }) const { mutate: saveComment } = useMutate({ verb: 'POST', path })
const { mutate: updateComment } = useMutate({ verb: 'PATCH', path: ({ id }) => `${path}/${id}` }) const { mutate: updateComment } = useMutate({ verb: 'PATCH', path: ({ id }) => `${path}/${id}` })
const { mutate: deleteComment } = useMutate({ verb: 'DELETE', 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 [dirty, setDirty] = useState(false)
const commentsRef = useRef<DiffCommentItem<TypesPullReqActivity>[]>(comments) const commentsRef = useRef<DiffCommentItem<TypesPullReqActivity>[]>(comments)
const setContainerRef = useCallback( const setContainerRef = useCallback(
@ -130,6 +132,19 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
[diffRenderer, renderCustomContent] [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( useEffect(
function createDiffRenderer() { function createDiffRenderer() {
if (inView && !diffRenderer) { if (inView && !diffRenderer) {
@ -231,7 +246,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
) )
useEffect( useEffect(
function renderAnnotatations() { function renderCodeComments() {
if (readOnly) { if (readOnly) {
return return
} }
@ -274,6 +289,28 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
rowElement.after(commentRowElement) rowElement.after(commentRowElement)
const element = commentRowElement.firstElementChild as HTMLTableCellElement 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 // Note: CommentBox is rendered as an independent React component
// everything passed to it must be either values, or refs. If you // everything passed to it must be either values, or refs. If you
@ -284,7 +321,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
<AppWrapper> <AppWrapper>
<CommentBox <CommentBox
commentItems={comment.commentItems} commentItems={comment.commentItems}
initialContent={getInitialCommentContentFromSelection(diff)} initialContent={''}
width={isSideBySide ? 'calc(100vw / 2 - 163px)' : undefined} // TODO: Re-calcualte for standalone version width={isSideBySide ? 'calc(100vw / 2 - 163px)' : undefined} // TODO: Re-calcualte for standalone version
onHeightChange={boxHeight => { onHeightChange={boxHeight => {
if (comment.height !== boxHeight) { if (comment.height !== boxHeight) {
@ -292,16 +329,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
setTimeout(() => setComments([...commentsRef.current]), 0) setTimeout(() => setComments([...commentsRef.current]), 0)
} }
}} }}
onCancel={() => { onCancel={resetCommentState}
// 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)
}}
setDirty={setDirty} setDirty={setDirty}
currentUserName={currentUser.display_name} currentUserName={currentUser.display_name}
handleAction={async (action, value, commentItem) => { handleAction={async (action, value, commentItem) => {
@ -325,6 +353,9 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
await saveComment(payload) await saveComment(payload)
.then((newComment: TypesPullReqActivity) => { .then((newComment: TypesPullReqActivity) => {
updatedItem = activityToCommentItem(newComment) updatedItem = activityToCommentItem(newComment)
diff.fileActivities?.push(newComment)
comment.commentItems.push(updatedItem)
resetCommentState(false)
}) })
.catch(exception => { .catch(exception => {
result = false result = false
@ -334,24 +365,19 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
} }
case CommentAction.REPLY: { case CommentAction.REPLY: {
const parentComment = diff.fileActivities?.find( await saveComment({
activity => diff.filePath === activity.code_comment?.path type: CommentType.CODE_COMMENT,
) text: value,
parent_id: Number(commentItem?.payload?.id as number)
if (parentComment) { })
await saveComment({ .then(newComment => {
type: CommentType.CODE_COMMENT, updatedItem = activityToCommentItem(newComment)
text: value, diff.fileActivities?.push(newComment)
parent_id: Number(parentComment.id as number) })
.catch(exception => {
result = false
showError(getErrorMessage(exception), 0)
}) })
.then(newComment => {
updatedItem = activityToCommentItem(newComment)
})
.catch(exception => {
result = false
showError(getErrorMessage(exception), 0)
})
}
break break
} }
@ -408,6 +434,14 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
onCommentUpdate={onCommentUpdate} onCommentUpdate={onCommentUpdate}
commentItems={comment.commentItems} commentItems={comment.commentItems}
/> />
),
[CommentBoxOutletPosition.BETWEEN_SAVE_AND_CANCEL_BUTTONS]: (props: ButtonProps) => (
<CodeCommentSecondarySaveButton
repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata as TypesPullReq}
commentItems={comment.commentItems}
{...props}
/>
) )
}} }}
autoFocusAndPositioning autoFocusAndPositioning
@ -541,7 +575,3 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
</Container> </Container>
) )
} }
function getInitialCommentContentFromSelection(_diff: DiffFileEntry) {
return ''
}

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,8 @@ import { useCallback, useState } from 'react'
export enum UserPreference { export enum UserPreference {
DIFF_VIEW_STYLE = 'DIFF_VIEW_STYLE', DIFF_VIEW_STYLE = 'DIFF_VIEW_STYLE',
DIFF_LINE_BREAKS = 'DIFF_LINE_BREAKS', 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] { 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. failedToDeleteComment: Failed to delete comment. Please try again.
failedToUpdateCommentStatus: Failed to update comment status. Please try again. failedToUpdateCommentStatus: Failed to update comment status. Please try again.
prMerged: This Pull Request was merged prMerged: This Pull Request was merged
prClosed: This Pull Request was closed.
reviewSubmitted: Review submitted. reviewSubmitted: Review submitted.
prReviewSubmit: '{user} {state|approved:approved, rejected:rejected,changereq:requested to, reviewed} the pull request. {time}' prReviewSubmit: '{user} {state|approved:approved, rejected:rejected,changereq:requested to, reviewed} the pull request. {time}'
prMergedInfo: '{user} merged branch {source} into {target} {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. 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? 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. notMergeableWithUnresolvedComment: This pull request has {unrsolvedComment} unresolved {unrsolvedComment|1:comment,comments}. Please resolve them before merging.
outdated: Outdated
prState: prState:
draftHeading: This pull request is still a work in progress draftHeading: This pull request is still a work in progress
draftDesc: Draft pull requests cannot be merged. draftDesc: Draft pull requests cannot be merged.
@ -238,6 +240,9 @@ viewed: Viewed
comment: Comment comment: Comment
addComment: Add comment addComment: Add comment
replyHere: Reply here... replyHere: Reply here...
reply: Reply
replyAndResolve: Reply & Resolve
replyAndReactivate: Reply & Reactivate
leaveAComment: Leave a comment... leaveAComment: Leave a comment...
lineBreaks: Line Breaks lineBreaks: Line Breaks
quote: Quote quote: Quote
@ -352,6 +357,8 @@ showEverything: Show everything
allComments: All comments allComments: All comments
whatsNew: What's new whatsNew: What's new
myComments: My comments/replies myComments: My comments/replies
resolvedComments: Resolved comments
unrsolvedComment: Unresolved comment
resetZoom: Reset Zoom resetZoom: Reset Zoom
zoomIn: Zoom In zoomIn: Zoom In
zoomOut: Zoom Out zoomOut: Zoom Out
@ -367,7 +374,7 @@ repoUpdate: Repository Updated
deleteRepoText: Are you sure you want to delete the repository '{REPONAME}'? deleteRepoText: Are you sure you want to delete the repository '{REPONAME}'?
deleteRepoTitle: Delete the repository deleteRepoTitle: Delete the repository
resolve: Resolve resolve: Resolve
unresolve: Unresolve reactivate: Reactivate
generateCloneCred: + Generate Clone Credential generateCloneCred: + Generate Clone Credential
generateCloneText: 'Please generate clone credential if its your first time' generateCloneText: 'Please generate clone credential if its your first time'
getMyCloneTitle: Get My Clone Credential getMyCloneTitle: Get My Clone Credential

View File

@ -15,7 +15,7 @@ interface CodeCommentHeaderProps {
} }
export const CodeCommentHeader: React.FC<CodeCommentHeaderProps> = ({ commentItems, threadId }) => { 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}` const id = `code-comment-snapshot-${threadId}`
useEffect(() => { useEffect(() => {

View File

@ -8,7 +8,7 @@ import { useAppContext } from 'AppContext'
import type { TypesPullReqActivity } from 'services/code' import type { TypesPullReqActivity } from 'services/code'
import { CommentAction, CommentBox, CommentBoxOutletPosition, CommentItem } from 'components/CommentBox/CommentBox' import { CommentAction, CommentBox, CommentBoxOutletPosition, CommentItem } from 'components/CommentBox/CommentBox'
import { useConfirmAct } from 'hooks/useConfirmAction' 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 { activityToCommentItem } from 'components/DiffViewer/DiffViewerUtils'
import { NavigationCheck } from 'components/NavigationCheck/NavigationCheck' import { NavigationCheck } from 'components/NavigationCheck/NavigationCheck'
import { ThreadSection } from 'components/ThreadSection/ThreadSection' import { ThreadSection } from 'components/ThreadSection/ThreadSection'
@ -28,13 +28,6 @@ export interface ConversationProps extends Pick<GitInfoProps, 'repoMetadata' | '
prHasChanged?: boolean prHasChanged?: boolean
} }
export enum prSortState {
SHOW_EVERYTHING = 'showEverything',
ALL_COMMENTS = 'allComments',
WHATS_NEW = 'whatsNew',
MY_COMMENTS = 'myComments'
}
export const Conversation: React.FC<ConversationProps> = ({ export const Conversation: React.FC<ConversationProps> = ({
repoMetadata, repoMetadata,
pullRequestMetadata, pullRequestMetadata,
@ -57,10 +50,8 @@ export const Conversation: React.FC<ConversationProps> = ({
}) })
const { showError } = useToaster() const { showError } = useToaster()
const [dateOrderSort, setDateOrderSort] = useState<boolean | 'desc' | 'asc'>(orderSortDate.ASC) const [dateOrderSort, setDateOrderSort] = useState<boolean | 'desc' | 'asc'>(orderSortDate.ASC)
const [prShowState, setPrShowState] = useState<SelectOption>({ const activityFilters = useActivityFilters()
label: `Show Everything `, const [activityFilter, setActivityFilter] = useState<SelectOption>(activityFilters[0] as SelectOption)
value: 'showEverything'
})
const activityBlocks = useMemo(() => { const activityBlocks = useMemo(() => {
// Each block may have one or more activities which are grouped into it. For example, one comment block // 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 // contains a parent comment and multiple replied comments
@ -86,61 +77,30 @@ export const Conversation: React.FC<ConversationProps> = ({
blocks.push(parentActivity.map(activityToCommentItem)) blocks.push(parentActivity.map(activityToCommentItem))
}) })
// Group title-change events into one single block switch (activityFilter.value) {
// Disabled for now, @see https://harness.atlassian.net/browse/SCM-79 case PRCommentFilterType.ALL_COMMENTS:
// const titleChangeItems = return blocks.filter(_activities => !isSystemComment(_activities))
// blocks.filter(
// _activities => isSystemComment(_activities) && _activities[0].payload?.type === CommentType.TITLE_CHANGE
// ) || []
// titleChangeItems.forEach((value, index) => { case PRCommentFilterType.RESOLVED_COMMENTS:
// if (index > 0) { return blocks.filter(_activities => isCodeComment(_activities) && _activities[0].payload?.resolved)
// titleChangeItems[0].push(...value)
// }
// })
// titleChangeItems.shift()
// return blocks.filter(_activities => !titleChangeItems.includes (_activities))
if (prShowState.value === prSortState.ALL_COMMENTS) { case PRCommentFilterType.UNRESOLVED_COMMENTS:
const allCommentBlock = blocks.filter(_activities => !isSystemComment(_activities)) return blocks.filter(_activities => isCodeComment(_activities) && !_activities[0].payload?.resolved)
return allCommentBlock
}
if (prShowState.value === prSortState.WHATS_NEW) { case PRCommentFilterType.MY_COMMENTS: {
// get current time in seconds and subtract it by a day and see if comments are newer than a day const allCommentBlock = blocks.filter(_activities => !isSystemComment(_activities))
const lastComment = blocks[blocks.length - 1] const userCommentsOnly = allCommentBlock.filter(_activities => {
const lastCommentTime = lastComment[lastComment.length - 1].payload?.edited const userCommentReply = _activities.filter(
if (lastCommentTime !== undefined) { authorIsUser => authorIsUser.payload?.author?.uid === currentUser.uid
const currentTime = lastCommentTime - dayAgoInMS )
return userCommentReply.length !== 0
const newestBlock = blocks.filter(_activities => {
const mostRecentComment = _activities[_activities.length - 1]
if (mostRecentComment?.payload?.edited !== undefined) {
return mostRecentComment?.payload?.edited > currentTime
}
}) })
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 return blocks
}, [activities, dateOrderSort, prShowState, currentUser.uid]) }, [activities, dateOrderSort, activityFilter, currentUser.uid])
const path = useMemo( const path = useMemo(
() => `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata.number}/comments`, () => `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata.number}/comments`,
[repoMetadata.path, pullRequestMetadata.number] [repoMetadata.path, pullRequestMetadata.number]
@ -183,28 +143,11 @@ export const Conversation: React.FC<ConversationProps> = ({
<Layout.Horizontal className={css.sortContainer} padding={{ top: 'xxlarge', bottom: 'medium' }}> <Layout.Horizontal className={css.sortContainer} padding={{ top: 'xxlarge', bottom: 'medium' }}>
<Container> <Container>
<Select <Select
items={[ items={activityFilters}
{ value={activityFilter}
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}
className={css.selectButton} className={css.selectButton}
onChange={newState => { onChange={newState => {
setPrShowState(newState) setActivityFilter(newState)
refetchActivities() refetchActivities()
}} }}
/> />
@ -387,3 +330,41 @@ export const Conversation: React.FC<ConversationProps> = ({
</PullRequestTabContentWrapper> </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; background-color: var(--red-50) !important;
} }
&.closed {
background-color: var(--grey-100) !important;
}
&.draft {
background-color: var(--orange-100) !important;
}
&.unchecked { &.unchecked {
background-color: #fcf4e3 !important; // Note: No UICore color variable for this background background-color: #fcf4e3 !important; // Note: No UICore color variable for this background
} }
@ -48,12 +56,16 @@
line-height: 20px !important; line-height: 20px !important;
color: var(--green-800) !important; color: var(--green-800) !important;
&.closed {
color: var(--grey-600) !important;
}
&.merged { &.merged {
color: var(--purple-700) !important; color: var(--purple-700) !important;
} }
&.draft { &.draft {
color: var(--grey-700) !important; color: var(--orange-900) !important;
} }
&.unmergeable { &.unmergeable {
@ -106,6 +118,11 @@
--background-color-active: var(--grey-100) !important; --background-color-active: var(--grey-100) !important;
} }
&.disabled {
pointer-events: none;
opacity: 0.5;
}
a, a,
button { button {
--background-color: var(--green-800) !important; --background-color: var(--green-800) !important;

View File

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

View File

@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react' import React, { useMemo } from 'react'
import { import {
Button, Button,
ButtonVariation, ButtonVariation,
@ -32,6 +32,7 @@ import { useConfirmAct } from 'hooks/useConfirmAction'
import { useAppContext } from 'AppContext' import { useAppContext } from 'AppContext'
import { Images } from 'images' import { Images } from 'images'
import { getErrorMessage, MergeCheckStatus, permissionProps } from 'utils/Utils' import { getErrorMessage, MergeCheckStatus, permissionProps } from 'utils/Utils'
import { UserPreference, useUserPreference } from 'hooks/useUserPreference'
import ReviewSplitButton from 'components/Changes/ReviewSplitButton/ReviewSplitButton' import ReviewSplitButton from 'components/Changes/ReviewSplitButton/ReviewSplitButton'
import css from './PullRequestActionsBox.module.scss' import css from './PullRequestActionsBox.module.scss'
@ -53,6 +54,7 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
}) => { }) => {
const { getString } = useStrings() const { getString } = useStrings()
const { showError } = useToaster() const { showError } = useToaster()
const { currentUser } = useAppContext()
const { hooks, standalone } = useAppContext() const { hooks, standalone } = useAppContext()
const space = useGetSpaceParam() const space = useGetSpaceParam()
const { mutate: mergePR, loading } = useMutate({ const { mutate: mergePR, loading } = useMutate({
@ -67,9 +69,11 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
() => pullRequestMetadata.merge_check_status === MergeCheckStatus.MERGEABLE, () => pullRequestMetadata.merge_check_status === MergeCheckStatus.MERGEABLE,
[pullRequestMetadata] [pullRequestMetadata]
) )
const isClosed = pullRequestMetadata.state === PullRequestState.CLOSED
const isOpen = pullRequestMetadata.state === PullRequestState.OPEN
const unchecked = useMemo( const unchecked = useMemo(
() => pullRequestMetadata.merge_check_status === MergeCheckStatus.UNCHECKED, () => pullRequestMetadata.merge_check_status === MergeCheckStatus.UNCHECKED && !isClosed,
[pullRequestMetadata] [pullRequestMetadata, isClosed]
) )
const isDraft = pullRequestMetadata.is_draft const isDraft = pullRequestMetadata.is_draft
const mergeOptions: PRMergeOption[] = [ const mergeOptions: PRMergeOption[] = [
@ -77,18 +81,19 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
method: 'squash', method: 'squash',
title: getString('pr.mergeOptions.squashAndMerge'), title: getString('pr.mergeOptions.squashAndMerge'),
desc: getString('pr.mergeOptions.squashAndMergeDesc'), desc: getString('pr.mergeOptions.squashAndMergeDesc'),
disabled: false disabled: mergeable === false
}, },
{ {
method: 'merge', method: 'merge',
title: getString('pr.mergeOptions.createMergeCommit'), title: getString('pr.mergeOptions.createMergeCommit'),
desc: getString('pr.mergeOptions.createMergeCommitDesc') desc: getString('pr.mergeOptions.createMergeCommitDesc'),
disabled: mergeable === false
}, },
{ {
method: 'rebase', method: 'rebase',
title: getString('pr.mergeOptions.rebaseAndMerge'), title: getString('pr.mergeOptions.rebaseAndMerge'),
desc: getString('pr.mergeOptions.rebaseAndMergeDesc'), desc: getString('pr.mergeOptions.rebaseAndMergeDesc'),
disabled: false disabled: mergeable === false
}, },
{ {
method: 'close', method: 'close',
@ -106,7 +111,11 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
}, },
[space] [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?.( const permPushResult = hooks?.usePermissionTranslate?.(
{ {
resource: { resource: {
@ -116,6 +125,11 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
}, },
[space] [space]
) )
const isActiveUserPROwner = useMemo(() => {
return (
!!currentUser?.uid && !!pullRequestMetadata?.author?.uid && currentUser?.uid === pullRequestMetadata?.author?.uid
)
}, [currentUser, pullRequestMetadata])
if (pullRequestMetadata.state === PullRequestFilterOption.MERGED) { if (pullRequestMetadata.state === PullRequestFilterOption.MERGED) {
return <MergeInfo pullRequestMetadata={pullRequestMetadata} /> return <MergeInfo pullRequestMetadata={pullRequestMetadata} />
@ -124,31 +138,46 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
return ( return (
<Container <Container
className={cx(css.main, { className={cx(css.main, {
[css.error]: mergeable === false && !unchecked, [css.error]: mergeable === false && !unchecked && !isClosed && !isDraft,
[css.unchecked]: unchecked [css.unchecked]: unchecked,
[css.closed]: isClosed,
[css.draft]: isDraft
})}> })}>
<Layout.Vertical spacing="xlarge"> <Layout.Vertical spacing="xlarge">
<Container> <Container>
<Layout.Horizontal spacing="small" flex={{ alignItems: 'center' }} className={css.layout}> <Layout.Horizontal spacing="small" flex={{ alignItems: 'center' }} className={css.layout}>
{(unchecked && <img src={Images.PrUnchecked} width={20} height={20} />) || ( {(unchecked && <img src={Images.PrUnchecked} width={20} height={20} />) || (
<Icon <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} 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 <Text
className={cx(css.sub, { className={cx(css.sub, {
[css.unchecked]: unchecked, [css.unchecked]: unchecked,
[css.draft]: isDraft, [css.draft]: isDraft,
[css.unmergeable]: mergeable === false [css.closed]: isClosed,
[css.unmergeable]: mergeable === false && isOpen
})}> })}>
{getString( {getString(
isDraft isDraft
? 'prState.draftHeading' ? 'prState.draftHeading'
: isClosed
? 'pr.prClosed'
: unchecked : unchecked
? 'pr.checkingToMerge' ? 'pr.checkingToMerge'
: mergeable === false : mergeable === false && isOpen
? 'pr.cantBeMerged' ? 'pr.cantBeMerged'
: 'pr.branchHasNoConflicts' : 'pr.branchHasNoConflicts'
)} )}
@ -196,6 +225,7 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
repoMetadata={repoMetadata} repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata} pullRequestMetadata={pullRequestMetadata}
refreshPr={onPRStateChanged} refreshPr={onPRStateChanged}
disabled={isActiveUserPROwner}
/> />
<Container <Container
inline inline
@ -265,29 +295,6 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
.catch(exception => showError(getErrorMessage(exception))) .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 => { {mergeOptions.map(option => {
return ( return (
<Menu.Item <Menu.Item

View File

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

View File

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

View File

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