Implement components to support PR commenting (#146)

This commit is contained in:
Tan Nhu 2022-12-28 15:58:02 -08:00 committed by GitHub
parent 1337f729e7
commit e4ae2b269b
28 changed files with 673 additions and 352 deletions

View File

@ -0,0 +1,66 @@
.main {
max-width: 900px;
box-sizing: border-box;
position: sticky;
left: 0;
background: var(--white) !important;
.box {
box-shadow: 0px 0px 1px rgba(40, 41, 61, 0.08), 0px 0.5px 2px rgba(96, 97, 112, 0.16);
border-radius: 4px;
:global {
.cm-editor .cm-line {
&,
* {
font-family: var(--font-family);
font-size: 13px;
}
}
}
.boxLayout {
box-shadow: 0px 0px 1px rgba(40, 41, 61, 0.08), 0px 0.5px 2px rgba(96, 97, 112, 0.16);
}
}
.viewer {
padding-bottom: var(--spacing-xsmall) !important;
:global {
.wmde-markdown .anchor {
display: none;
}
}
}
.replyPlaceHolder {
align-items: center;
> div:last-of-type {
flex-grow: 1;
padding: 0;
margin: 0;
}
}
}
.deleteMenuItem:hover {
background-color: var(--red-500);
* {
color: var(--white) !important;
}
}
.newCommentContainer {
border-top: 1px solid var(--grey-200);
padding-top: var(--spacing-xlarge) !important;
background: var(--grey-50) !important;
}
.editCommentContainer {
background-color: var(--grey-50) !important;
padding: var(--spacing-large) !important;
border-radius: 5px;
}

View File

@ -5,9 +5,9 @@ declare const styles: {
readonly box: string readonly box: string
readonly boxLayout: string readonly boxLayout: string
readonly viewer: string readonly viewer: string
readonly editor: string
readonly markdownEditor: string
readonly preview: string
readonly replyPlaceHolder: string readonly replyPlaceHolder: string
readonly deleteMenuItem: string
readonly newCommentContainer: string
readonly editCommentContainer: string
} }
export default styles export default styles

View File

@ -0,0 +1,229 @@
import React, { useCallback, useState } from 'react'
import { useResizeDetector } from 'react-resize-detector'
import { Container, Layout, Avatar, TextInput, Text, Color, FontVariation, FlexExpander } from '@harness/uicore'
import MarkdownEditor from '@uiw/react-markdown-editor'
import ReactTimeago from 'react-timeago'
import 'highlight.js/styles/github.css'
import 'diff2html/bundles/css/diff2html.min.css'
import type { UseStringsReturn } from 'framework/strings'
import { ThreadSection } from 'components/ThreadSection/ThreadSection'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import { CodeIcon } from 'utils/GitUtils'
import type { CommentThreadEntry, UserProfile } from 'utils/types'
import { MenuDivider, OptionsMenuButton } from 'components/OptionsMenuButton/OptionsMenuButton'
import { MarkdownEditorWithPreview } from 'components/MarkdownEditorWithPreview/MarkdownEditorWithPreview'
import css from './CommentBox.module.scss'
interface CommentBoxProps {
getString: UseStringsReturn['getString']
onHeightChange: (height: number) => void
onCancel: () => void
width?: string
commentsThread: CommentThreadEntry[]
currentUser: UserProfile
executeDeleteComent: (params: { commentEntry: CommentThreadEntry; onSuccess: () => void }) => void
}
export const CommentBox: React.FC<CommentBoxProps> = ({
getString,
onHeightChange,
onCancel,
width,
commentsThread: _commentsThread = [],
currentUser,
executeDeleteComent
}) => {
// TODO: \r\n for Windows or based on configuration
// @see https://www.aleksandrhovhannisyan.com/blog/crlf-vs-lf-normalizing-line-endings-in-git/
const CRLF = '\n'
const [commentsThread, setCommentsThread] = useState<CommentThreadEntry[]>(_commentsThread)
const [showReplyPlaceHolder, setShowReplyPlaceHolder] = useState(!!commentsThread.length)
const [markdown, setMarkdown] = useState('')
const { ref } = useResizeDetector({
refreshMode: 'debounce',
handleWidth: false,
refreshRate: 50,
observerOptions: {
box: 'border-box'
},
onResize: () => {
onHeightChange(ref.current?.offsetHeight)
}
})
const _onCancel = useCallback(() => {
setMarkdown('')
if (!commentsThread.length) {
onCancel()
} else {
setShowReplyPlaceHolder(true)
// onHeightChange('auto')
}
}, [commentsThread, setShowReplyPlaceHolder, onCancel])
const hidePlaceHolder = useCallback(() => {
setShowReplyPlaceHolder(false)
// onHeightChange('auto')
}, [setShowReplyPlaceHolder])
const onQuote = useCallback((content: string) => {
setShowReplyPlaceHolder(false)
// onHeightChange('auto')
setMarkdown(
content
.split(CRLF)
.map(line => `> ${line}`)
.concat([CRLF, CRLF])
.join(CRLF)
)
}, [])
return (
<Container className={css.main} padding="medium" width={width} ref={ref}>
<Container className={css.box}>
<Layout.Vertical className={css.boxLayout}>
<CommentsThread
commentsThread={commentsThread}
getString={getString}
currentUser={currentUser}
onQuote={onQuote}
executeDeleteComent={executeDeleteComent}
/>
{(showReplyPlaceHolder && (
<Container>
<Layout.Horizontal spacing="small" className={css.replyPlaceHolder} padding="medium">
<Avatar name={currentUser.name} size="small" hoverCard={false} />
<TextInput placeholder={getString('replyHere')} onFocus={hidePlaceHolder} onClick={hidePlaceHolder} />
</Layout.Horizontal>
</Container>
)) || (
<Container padding="xlarge" className={css.newCommentContainer}>
<MarkdownEditorWithPreview
placeHolder={getString(commentsThread.length ? 'replyHere' : 'leaveAComment')}
value={markdown}
editTabText={getString('write')}
previewTabText={getString('preview')}
saveButtonText={getString('addComment')}
cancelButtonText={getString('cancel')}
onChange={setMarkdown}
onSave={value => {
setCommentsThread([
...commentsThread,
{
id: '0',
author: currentUser.name,
created: Date.now().toString(),
updated: Date.now().toString(),
content: value
}
])
setMarkdown('')
setShowReplyPlaceHolder(true)
}}
onCancel={_onCancel}
/>
</Container>
)}
</Layout.Vertical>
</Container>
</Container>
)
}
interface CommentsThreadProps
extends Pick<CommentBoxProps, 'commentsThread' | 'getString' | 'currentUser' | 'executeDeleteComent'> {
onQuote: (content: string) => void
}
const CommentsThread: React.FC<CommentsThreadProps> = ({
getString,
currentUser,
onQuote,
commentsThread = [],
executeDeleteComent
}) => {
const [editIndexes, setEditIndexes] = useState<Record<number, boolean>>({})
return commentsThread.length ? (
<Container className={css.viewer} padding="xlarge">
{commentsThread.map((commentEntry, index) => {
const isLastItem = index === commentsThread.length - 1
return (
<ThreadSection
key={index}
title={
<Layout.Horizontal spacing="small" style={{ alignItems: 'center' }}>
<Text inline icon={CodeIcon.Chat}></Text>
<Avatar name={commentEntry.author} size="small" hoverCard={false} />
<Text inline>
<strong>{commentEntry.author}</strong>
</Text>
<PipeSeparator height={8} />
<Text inline font={{ variation: FontVariation.SMALL }} color={Color.GREY_400}>
<ReactTimeago date={new Date(commentEntry.updated)} />
</Text>
<FlexExpander />
<OptionsMenuButton
isDark={false}
icon="Options"
iconProps={{ size: 14 }}
style={{ padding: '5px' }}
items={[
{
text: getString('edit'),
onClick: () => setEditIndexes({ ...editIndexes, ...{ [index]: true } })
},
{
text: getString('quote'),
onClick: () => {
onQuote(commentEntry.content)
}
},
MenuDivider,
{
text: (
<Text width={100} color={Color.RED_500}>
{getString('delete')}
</Text>
),
onClick: () =>
executeDeleteComent({
commentEntry,
onSuccess: () => {
alert('success')
}
}),
className: css.deleteMenuItem
}
]}
/>
</Layout.Horizontal>
}
hideGutter={isLastItem}>
<Container
padding={{ left: editIndexes[index] ? undefined : 'medium', bottom: isLastItem ? undefined : 'xsmall' }}>
{editIndexes[index] ? (
<Container className={css.editCommentContainer}>
<MarkdownEditorWithPreview
placeHolder={getString('leaveAComment')}
value={commentEntry.content}
editTabText={getString('write')}
previewTabText={getString('preview')}
saveButtonText={getString('save')}
cancelButtonText={getString('cancel')}
onSave={(value, original) => {}}
onCancel={() => {
delete editIndexes[index]
setEditIndexes({ ...editIndexes })
}}
/>
</Container>
) : (
<MarkdownEditor.Markdown key={index} source={commentEntry.content} />
)}
</Container>
</ThreadSection>
)
})}
</Container>
) : null
}

View File

@ -1,108 +0,0 @@
.main {
max-width: 900px;
box-sizing: border-box;
position: sticky;
left: 0;
background: var(--white) !important;
.box {
box-shadow: 0px 0px 1px rgba(40, 41, 61, 0.08), 0px 0.5px 2px rgba(96, 97, 112, 0.16);
border-radius: 4px;
:global {
.cm-editor .cm-line {
&,
* {
font-family: var(--font-family);
font-size: 13px;
}
}
.md-editor-preview {
display: none;
}
.md-editor,
.md-editor-toolbar-warp {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.md-editor-content {
padding: var(--spacing-small) var(--spacing-xsmall);
background-color: var(--white);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
.ͼ1.cm-editor.cm-focused {
outline: none !important;
}
}
.cm-content {
min-height: 46px;
}
// .d2h-code-line,
// .d2h-code-line-ctn {
// white-space: pre-wrap;
// }
}
.boxLayout {
box-shadow: 0px 0px 1px rgba(40, 41, 61, 0.08), 0px 0.5px 2px rgba(96, 97, 112, 0.16);
}
}
.viewer {
padding-bottom: var(--spacing-xsmall) !important;
:global {
.wmde-markdown .anchor {
display: none;
}
}
}
.editor {
background-color: var(--grey-50);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-top: 1px solid var(--grey-100);
> div:first-child {
padding-top: 0 !important;
}
[role='tablist'] {
background: none !important;
}
.markdownEditor {
:global {
.md-editor {
--color-border-shadow: 0 0 0 1px rgb(178 178 178 / 10%), 0 0 0 rgb(223 223 223 / 0%),
0 1px 1px rgb(104 109 112 / 20%);
&:focus-within {
--color-border-shadow: 0 0 0 2px #1d86c7, 0 0 0 #059fff, 0 0px 0px rgb(104 109 112 / 20%);
}
}
}
}
.preview {
background-color: var(--white);
}
}
.replyPlaceHolder {
align-items: center;
> div:last-of-type {
flex-grow: 1;
padding: 0;
margin: 0;
}
}
}

View File

@ -1,196 +0,0 @@
import React, { useCallback, useState } from 'react'
import { useResizeDetector } from 'react-resize-detector'
import {
Button,
Container,
ButtonVariation,
Layout,
Avatar,
TextInput,
Text,
Color,
FontVariation
} from '@harness/uicore'
import MarkdownEditor from '@uiw/react-markdown-editor'
import { Tab, Tabs } from '@blueprintjs/core'
import { indentWithTab } from '@codemirror/commands'
import ReactTimeago from 'react-timeago'
import { keymap } from '@codemirror/view'
import 'highlight.js/styles/github.css'
import 'diff2html/bundles/css/diff2html.min.css'
import type { CurrentUser } from 'hooks/useCurrentUser'
import type { UseStringsReturn } from 'framework/strings'
import { ThreadSection } from 'components/ThreadSection/ThreadSection'
import { CodeIcon } from 'utils/GitUtils'
import css from './CommentBox.module.scss'
interface CommentBoxProps {
getString: UseStringsReturn['getString']
onHeightChange: (height: number | 'auto') => void
onCancel: () => void
width?: string
contents?: string[]
currentUser: CurrentUser
}
export const CommentBox: React.FC<CommentBoxProps> = ({
getString,
onHeightChange,
onCancel,
width,
contents: _contents = [],
currentUser
}) => {
const [contents, setContents] = useState<string[]>(_contents)
const [showReplyPlaceHolder, setShowReplyPlaceHolder] = useState(!!contents.length)
const [markdown, setMarkdown] = useState('')
const { ref } = useResizeDetector({
refreshMode: 'debounce',
handleWidth: false,
refreshRate: 50,
observerOptions: {
box: 'border-box'
},
onResize: () => {
onHeightChange(ref.current?.offsetHeight)
}
})
// Note: Send 'auto' to avoid render flickering
const onCancelBtnClick = useCallback(() => {
if (!contents.length) {
onCancel()
} else {
setShowReplyPlaceHolder(true)
onHeightChange('auto')
}
}, [contents, setShowReplyPlaceHolder, onCancel, onHeightChange])
const hidePlaceHolder = useCallback(() => {
setShowReplyPlaceHolder(false)
onHeightChange('auto')
}, [setShowReplyPlaceHolder, onHeightChange])
return (
<Container className={css.main} padding="medium" width={width} ref={ref}>
<Container className={css.box}>
<Layout.Vertical className={css.boxLayout}>
{!!contents.length && (
<Container className={css.viewer} padding="xlarge">
{contents.map((content, index) => {
const isLastItem = index === contents.length - 1
return (
<ThreadSection
key={index}
title={
<Layout.Horizontal spacing="small" style={{ alignItems: 'center' }}>
<Text inline icon={CodeIcon.Chat}></Text>
<Avatar name={currentUser.name} size="small" hoverCard={false} />
<Text inline>
<strong>{currentUser.name}</strong>
</Text>
<Text inline font={{ variation: FontVariation.SMALL }} color={Color.GREY_400}>
<ReactTimeago date={'2022-12-22'} />
</Text>
</Layout.Horizontal>
}
hideGutter={isLastItem}>
<Container padding={{ left: 'medium', bottom: isLastItem ? undefined : 'xsmall' }}>
<MarkdownEditor.Markdown key={index} source={content} />
</Container>
</ThreadSection>
)
})}
</Container>
)}
<Container className={css.editor}>
{(showReplyPlaceHolder && (
<Container>
<Layout.Horizontal spacing="small" className={css.replyPlaceHolder} padding="medium">
<Avatar name={currentUser.name} size="small" hoverCard={false} />
<TextInput placeholder={getString('replyHere')} onFocus={hidePlaceHolder} onClick={hidePlaceHolder} />
</Layout.Horizontal>
</Container>
)) || (
<Container padding="xlarge">
<Layout.Vertical spacing="large">
<Tabs animate={true} id="CommentBoxTabs" onChange={() => onHeightChange('auto')} key="horizontal">
<Tabs.Expander />
<Tab
id="write"
title="Write"
panel={
<Container className={css.markdownEditor}>
<MarkdownEditor
value={markdown}
visible={false}
placeholder={getString(contents.length ? 'replyHere' : 'leaveAComment')}
theme="light"
indentWithTab={false}
autoFocus
toolbars={[
// 'header',
'bold',
'italic',
'strike',
'quote',
'olist',
'ulist',
'todo',
'link',
'image',
'codeBlock'
]}
toolbarsMode={[]}
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false
}}
extensions={[keymap.of([indentWithTab])]}
onChange={(value, _viewUpdate) => {
onHeightChange('auto')
setMarkdown(value)
}}
/>
</Container>
}
/>
<Tab
id="preview"
disabled={!markdown}
title="Preview"
panel={
<Container padding="large" className={css.preview}>
<MarkdownEditor.Markdown source={markdown} />
</Container>
}
/>
</Tabs>
<Container>
<Layout.Horizontal spacing="small">
<Button
disabled={!(markdown || '').trim()}
variation={ButtonVariation.PRIMARY}
onClick={() => {
setContents([...contents, markdown])
setMarkdown('')
setShowReplyPlaceHolder(true)
onHeightChange('auto')
}}
text={getString('addComment')}
/>
<Button
variation={ButtonVariation.TERTIARY}
onClick={onCancelBtnClick}
text={getString('cancel')}
/>
</Layout.Horizontal>
</Container>
</Layout.Vertical>
</Container>
)}
</Container>
</Layout.Vertical>
</Container>
</Container>
)
}

View File

@ -1,16 +1,28 @@
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { useInView } from 'react-intersection-observer' import { useInView } from 'react-intersection-observer'
import { Button, Color, Container, FlexExpander, ButtonVariation, Layout, Text, ButtonSize } from '@harness/uicore' import {
Button,
Color,
Container,
FlexExpander,
ButtonVariation,
Layout,
Text,
ButtonSize,
Intent
} from '@harness/uicore'
import { Diff2HtmlUI } from 'diff2html/lib-esm/ui/js/diff2html-ui' import { Diff2HtmlUI } from 'diff2html/lib-esm/ui/js/diff2html-ui'
import 'highlight.js/styles/github.css' import 'highlight.js/styles/github.css'
import 'diff2html/bundles/css/diff2html.min.css' import 'diff2html/bundles/css/diff2html.min.css'
import { noop } from 'lodash-es'
import { useStrings } from 'framework/strings' import { useStrings } from 'framework/strings'
import { CodeIcon } from 'utils/GitUtils' import { CodeIcon } from 'utils/GitUtils'
import { useEventListener } from 'hooks/useEventListener' import { useEventListener } from 'hooks/useEventListener'
import type { DiffFileEntry } from 'utils/types' import type { DiffFileEntry } from 'utils/types'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator' import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import { useCurrentUser } from 'hooks/useCurrentUser' import { useCurrentUser } from 'hooks/useCurrentUser'
import { useConfirmAction } from 'hooks/useConfirmAction'
import { import {
CommentItem, CommentItem,
DIFF2HTML_CONFIG, DIFF2HTML_CONFIG,
@ -19,7 +31,7 @@ import {
renderCommentOppositePlaceHolder, renderCommentOppositePlaceHolder,
ViewStyle ViewStyle
} from './DiffViewerUtils' } from './DiffViewerUtils'
import { CommentBox } from './CommentBox/CommentBox' import { CommentBox } from '../CommentBox/CommentBox'
import css from './DiffViewer.module.scss' import css from './DiffViewer.module.scss'
interface DiffViewerProps { interface DiffViewerProps {
@ -46,6 +58,17 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({ diff, index, viewStyle,
const { ref: inViewRef, inView } = useInView({ rootMargin: '100px 0px' }) const { ref: inViewRef, inView } = useInView({ rootMargin: '100px 0px' })
const containerRef = useRef<HTMLDivElement | null>(null) const containerRef = useRef<HTMLDivElement | null>(null)
const currentUser = useCurrentUser() const currentUser = useCurrentUser()
const executeDeleteComentConfirmation = useConfirmAction({
title: getString('delete'),
intent: Intent.DANGER,
message: <Text>{getString('deleteCommentConfirm')}</Text>,
action: async ({ commentEntry, onSuccess = noop }) => {
// TODO: Delete comment
console.log('Deleting...', commentEntry)
onSuccess('Delete ', commentEntry)
}
})
const [comments, setComments] = useState<CommentItem[]>( const [comments, setComments] = useState<CommentItem[]>(
!index !index
? [ ? [
@ -54,13 +77,19 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({ diff, index, viewStyle,
right: true, right: true,
height: 0, height: 0,
lineNumber: 11, lineNumber: 11,
contents: [ commentsThread: [
`Logs will looks similar to\n\n<img width="1494" alt="image" src="https://user-images.githubusercontent.com/98799615/207994246-19ce9eb2-604f-4226-9a3c-6f4125d3b7cc.png">\n\n\ngitrpc logs using the \`ctx\` will have the following annotations:\n- \`grpc.service=rpc.ReferenceService\`\n- \`grpc.method=CreateBranch\`\n- \`grpc.peer=127.0.0.1:49364\`\n- \`grpc.request_id=cedrl6p1eqltblt13mgg\``, `Logs will looks similar to\n\n<img width="1494" alt="image" src="https://user-images.githubusercontent.com/98799615/207994246-19ce9eb2-604f-4226-9a3c-6f4125d3b7cc.png">\n\n\ngitrpc logs using the \`ctx\` will have the following annotations:\n- \`grpc.service=rpc.ReferenceService\`\n- \`grpc.method=CreateBranch\`\n- \`grpc.peer=127.0.0.1:49364\`\n- \`grpc.request_id=cedrl6p1eqltblt13mgg\``,
// `it seems we don't actually do anything with the explicit error type other than calling .Error(), which technically we could do on the original err object too? unless I'm missing something, could we then use errors.Is instead? (would avoid the extra var definitions at the top)`, // `it seems we don't actually do anything with the explicit error type other than calling .Error(), which technically we could do on the original err object too? unless I'm missing something, could we then use errors.Is instead? (would avoid the extra var definitions at the top)`,
//`If error is not converted then it will be detailed error: in BranchDelete: Branch doesn't exists. What we want is human readable error: Branch 'name' doesn't exists.`, //`If error is not converted then it will be detailed error: in BranchDelete: Branch doesn't exists. What we want is human readable error: Branch 'name' doesn't exists.`,
// `* GitRPC isolated errors, bcoz this will be probably separate repo in future and we dont want every where to include grpc status codes in our main app\n* Errors are explicit for repsonses based on error passing by types`, // `* GitRPC isolated errors, bcoz this will be probably separate repo in future and we dont want every where to include grpc status codes in our main app\n* Errors are explicit for repsonses based on error passing by types`,
`> global ctx in wire will kill all routines, right? is this affect middlewares and interceptors? because requests should finish they work, right?\n\nI've changed the code now to pass the config directly instead of the systemstore and context, to avoid confusion (what we discussed yesterday - I remove systemstore itself another time).` `> global ctx in wire will kill all routines, right? is this affect middlewares and interceptors? because requests should finish they work, right?\n\nI've changed the code now to pass the config directly instead of the systemstore and context, to avoid confusion (what we discussed yesterday - I remove systemstore itself another time).`
] ].map(content => ({
id: '0',
author: 'Tan Nhu',
created: '2022-12-21',
updated: '2022-12-21',
content
}))
} }
] ]
: [] : []
@ -168,7 +197,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({ diff, index, viewStyle,
right: false, right: false,
lineNumber: 0, lineNumber: 0,
height: 0, height: 0,
contents: [] commentsThread: []
} }
if (targetButton && annotatedLineRow) { if (targetButton && annotatedLineRow) {
@ -244,19 +273,15 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({ diff, index, viewStyle,
ReactDOM.unmountComponentAtNode(element as HTMLDivElement) ReactDOM.unmountComponentAtNode(element as HTMLDivElement)
ReactDOM.render( ReactDOM.render(
<CommentBox <CommentBox
contents={comment.contents} commentsThread={comment.commentsThread}
getString={getString} getString={getString}
width={isSideBySide ? 'calc(100vw / 2 - 163px)' : undefined} width={isSideBySide ? 'calc(100vw / 2 - 163px)' : undefined}
onHeightChange={boxHeight => { onHeightChange={boxHeight => {
if (typeof boxHeight === 'string') {
element.style.height = boxHeight
} else {
if (comment.height !== boxHeight) { if (comment.height !== boxHeight) {
comment.height = boxHeight comment.height = boxHeight
element.style.height = `${boxHeight}px` // element.style.height = `${boxHeight}px`
setTimeout(() => setComments([...commentsRef.current]), 0) setTimeout(() => setComments([...commentsRef.current]), 0)
} }
}
}} }}
onCancel={() => { onCancel={() => {
// Clean up CommentBox rendering and reset states bound to lineInfo // Clean up CommentBox rendering and reset states bound to lineInfo
@ -269,6 +294,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({ diff, index, viewStyle,
setTimeout(() => setComments(commentsRef.current.filter(item => item !== comment)), 0) setTimeout(() => setComments(commentsRef.current.filter(item => item !== comment)), 0)
}} }}
currentUser={currentUser} currentUser={currentUser}
executeDeleteComent={executeDeleteComentConfirmation}
/>, />,
element element
) )
@ -278,13 +304,14 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({ diff, index, viewStyle,
renderCommentOppositePlaceHolder(comment, lineInfo.oppositeRowElement) renderCommentOppositePlaceHolder(comment, lineInfo.oppositeRowElement)
} }
} }
} else {
// Comment no longer has UI relevant anchors to be rendered
console.info('Comment is discarded due to no UI relevant anchors', { comment, lineInfo })
} }
// Comment no longer has UI relevant anchors to be rendered
// else {
// console.info('Comment is discarded due to no UI relevant anchors', { comment, lineInfo })
// }
}) })
}, },
[comments, viewStyle, getString, currentUser] [comments, viewStyle, getString, currentUser, executeDeleteComentConfirmation]
) )
useEffect(function cleanUpCommentBoxRendering() { useEffect(function cleanUpCommentBoxRendering() {

View File

@ -2,6 +2,7 @@ import type * as Diff2Html from 'diff2html'
import HoganJsUtils from 'diff2html/lib/hoganjs-utils' import HoganJsUtils from 'diff2html/lib/hoganjs-utils'
import 'highlight.js/styles/github.css' import 'highlight.js/styles/github.css'
import 'diff2html/bundles/css/diff2html.min.css' import 'diff2html/bundles/css/diff2html.min.css'
import type { CommentThreadEntry } from 'utils/types'
export enum ViewStyle { export enum ViewStyle {
SIDE_BY_SIDE = 'side-by-side', SIDE_BY_SIDE = 'side-by-side',
@ -17,7 +18,7 @@ export interface CommentItem {
right: boolean right: boolean
lineNumber: number lineNumber: number
height: number height: number
contents: string[] commentsThread: CommentThreadEntry[]
} }
export const DIFF2HTML_CONFIG = { export const DIFF2HTML_CONFIG = {

View File

@ -0,0 +1,104 @@
.main {
--color-border: var(--grey-200);
background-color: var(--grey-50);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
position: relative;
&:focus-within {
--color-border: var(--primary-7);
}
&.withPreview {
--color-border: var(--grey-200);
}
> div:first-child {
padding-top: 0 !important;
}
.tabs {
:global {
.bp3-tab-list {
border: none;
background-color: transparent !important;
position: absolute;
top: -4px;
right: 25px;
z-index: 2;
padding: 0;
.bp3-tab {
margin: 0 !important;
padding: 6px 10px !important;
border-bottom: 1px solid transparent;
&[aria-selected='true'] {
background-color: var(--white) !important;
border-top: 1px solid var(--color-border);
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
&:not([aria-selected='true']) {
border-bottom: 1px solid transparent;
}
}
.bp3-tab-indicator-wrapper {
display: none;
}
}
}
}
.preview {
background-color: var(--white);
margin-top: 28px !important;
border: 1px solid var(--color-border) !important;
border-radius: 5px;
}
.markdownEditor {
:global {
.md-editor {
background-color: transparent !important;
box-shadow: none !important;
.md-editor-content {
border: 1px solid var(--color-border) !important;
border-radius: 5px;
}
}
.md-editor-toolbar-warp,
.md-editor-toolbar-warp:not(.md-editor-toolbar-bottom) {
border-bottom: none !important;
}
.md-editor-preview {
display: none;
}
.md-editor-toolbar {
transform: translateY(-5px);
}
.md-editor-content {
padding: var(--spacing-small);
background-color: var(--white);
.ͼ1.cm-editor.cm-focused {
outline: none !important;
}
}
.cm-content {
min-height: 46px;
}
}
}
}

View File

@ -0,0 +1,10 @@
/* eslint-disable */
// this is an auto-generated file
declare const styles: {
readonly main: string
readonly withPreview: string
readonly tabs: string
readonly preview: string
readonly markdownEditor: string
}
export default styles

View File

@ -0,0 +1,110 @@
import React, { useState } from 'react'
import { Button, Container, ButtonVariation, Layout } from '@harness/uicore'
import MarkdownEditor from '@uiw/react-markdown-editor'
import { Tab, Tabs } from '@blueprintjs/core'
import { indentWithTab } from '@codemirror/commands'
import cx from 'classnames'
import { keymap, EditorView } from '@codemirror/view'
import { noop } from 'lodash-es'
import 'highlight.js/styles/github.css'
import 'diff2html/bundles/css/diff2html.min.css'
import type { IToolBarProps } from '@uiw/react-markdown-editor/cjs/components/ToolBar'
import css from './MarkdownEditorWithPreview.module.scss'
interface MarkdownEditorWithPreviewProps {
placeHolder: string
value: string
editTabText: string
previewTabText: string
cancelButtonText: string
saveButtonText: string
onChange?: (value: string, original: string) => void
onSave: (value: string, original: string) => void
onCancel: () => void
}
export function MarkdownEditorWithPreview({
placeHolder,
value,
editTabText,
previewTabText,
saveButtonText,
cancelButtonText,
onChange = noop,
onSave,
onCancel
}: MarkdownEditorWithPreviewProps) {
const [original] = useState(value)
const [selectedTab, setSelectedTab] = useState<MarkdownEditorTab>(MarkdownEditorTab.WRITE)
const [val, setVal] = useState(value)
return (
<Container className={cx(css.main, selectedTab === MarkdownEditorTab.PREVIEW ? css.withPreview : '')}>
<Layout.Vertical spacing="large">
<Tabs
className={css.tabs}
defaultSelectedTabId={selectedTab}
onChange={tabId => setSelectedTab(tabId as MarkdownEditorTab)}>
<Tab
id={MarkdownEditorTab.WRITE}
title={editTabText}
panel={
<Container className={css.markdownEditor}>
<MarkdownEditor
value={val}
visible={false}
placeholder={placeHolder}
theme="light"
indentWithTab={false}
autoFocus
// TODO: Customize toolbars to show tooltip.
// @see https://github.com/uiwjs/react-markdown-editor#custom-toolbars
toolbars={toolbars}
toolbarsMode={[]}
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false
}}
extensions={[keymap.of([indentWithTab]), EditorView.lineWrapping]}
onChange={(_value, _viewUpdate) => {
setVal(_value)
onChange(_value, original)
}}
/>
</Container>
}
/>
<Tab
id={MarkdownEditorTab.PREVIEW}
disabled={!value}
title={previewTabText}
panel={
<Container padding="large" className={css.preview}>
<MarkdownEditor.Markdown source={val} />
</Container>
}
/>
</Tabs>
<Container>
<Layout.Horizontal spacing="small">
<Button
disabled={!(val || '').trim() || val === original}
variation={ButtonVariation.PRIMARY}
onClick={() => onSave(val, original)}
text={saveButtonText}
/>
<Button variation={ButtonVariation.TERTIARY} onClick={onCancel} text={cancelButtonText} />
</Layout.Horizontal>
</Container>
</Layout.Vertical>
</Container>
)
}
const toolbars: IToolBarProps['toolbars'] = ['bold', 'strike', 'olist', 'ulist', 'todo', 'link', 'image']
enum MarkdownEditorTab {
WRITE = 'write',
PREVIEW = 'preview'
}

View File

@ -7,14 +7,21 @@ export const MenuDivider = '-' as const
export interface OptionsMenuButtonProps extends ButtonProps { export interface OptionsMenuButtonProps extends ButtonProps {
items: Array<React.ComponentProps<typeof Menu.Item> | '-'> items: Array<React.ComponentProps<typeof Menu.Item> | '-'>
isDark?: boolean
icon?: ButtonProps['icon']
} }
export const OptionsMenuButton = ({ items, ...props }: OptionsMenuButtonProps): ReactElement => { export const OptionsMenuButton = ({
items,
icon = 'code-more',
isDark = true,
...props
}: OptionsMenuButtonProps): ReactElement => {
return ( return (
<Button <Button
minimal minimal
icon="code-more" icon={icon}
tooltipProps={{ isDark: true, interactionKind: 'click', hasBackdrop: true } as PopoverProps} tooltipProps={{ isDark, interactionKind: 'click', hasBackdrop: true } as PopoverProps}
tooltip={ tooltip={
<Menu style={{ minWidth: 'unset' }}> <Menu style={{ minWidth: 'unset' }}>
{items.map( {items.map(

View File

@ -67,6 +67,7 @@ export interface StringsMap {
delete: string delete: string
deleteBranch: string deleteBranch: string
deleteBranchConfirm: string deleteBranchConfirm: string
deleteCommentConfirm: string
deleteFile: string deleteFile: string
deleteNotAllowed: string deleteNotAllowed: string
deployKeys: string deployKeys: string
@ -145,12 +146,14 @@ export interface StringsMap {
prefixBase: string prefixBase: string
prefixCompare: string prefixCompare: string
prev: string prev: string
preview: string
private: string private: string
public: string public: string
pullMustBeMadeFromBranches: string pullMustBeMadeFromBranches: string
pullRequestEmpty: string pullRequestEmpty: string
pullRequests: string pullRequests: string
pushEvent: string pushEvent: string
quote: string
rejected: string rejected: string
renameFile: string renameFile: string
replyHere: string replyHere: string
@ -163,6 +166,7 @@ export interface StringsMap {
'repos.updated': string 'repos.updated': string
repositories: string repositories: string
samplePayloadUrl: string samplePayloadUrl: string
save: string
scanAlerts: string scanAlerts: string
scrollToTop: string scrollToTop: string
search: string search: string
@ -191,6 +195,7 @@ export interface StringsMap {
webhookEventsLabel: string webhookEventsLabel: string
webhookListingContent: string webhookListingContent: string
webhooks: string webhooks: string
write: string
yourBranches: string yourBranches: string
yours: string yours: string
} }

View File

@ -6,6 +6,7 @@
*/ */
import { Intent } from '@blueprintjs/core' import { Intent } from '@blueprintjs/core'
import { useCallback, useState } from 'react'
import { useConfirmationDialog } from '@harness/uicore' import { useConfirmationDialog } from '@harness/uicore'
import { useStrings } from 'framework/strings' import { useStrings } from 'framework/strings'
@ -15,12 +16,13 @@ export interface UseConfirmActionDialogProps {
title?: string title?: string
confirmText?: string confirmText?: string
cancelText?: string cancelText?: string
action: () => void action: (params?: Unknown) => void
} }
export const useConfirmAction = (props: UseConfirmActionDialogProps) => { export const useConfirmAction = (props: UseConfirmActionDialogProps) => {
const { title, message, confirmText, cancelText, intent, action } = props const { title, message, confirmText, cancelText, intent, action } = props
const { getString } = useStrings() const { getString } = useStrings()
const [params, setParams] = useState<Unknown>()
const { openDialog } = useConfirmationDialog({ const { openDialog } = useConfirmationDialog({
intent, intent,
titleText: title || getString('confirmation'), titleText: title || getString('confirmation'),
@ -30,10 +32,17 @@ export const useConfirmAction = (props: UseConfirmActionDialogProps) => {
buttonIntent: intent || Intent.DANGER, buttonIntent: intent || Intent.DANGER,
onCloseDialog: async (isConfirmed: boolean) => { onCloseDialog: async (isConfirmed: boolean) => {
if (isConfirmed) { if (isConfirmed) {
action() action(params)
} }
} }
}) })
const confirm = useCallback(
(_params?: Unknown) => {
setParams(_params)
openDialog()
},
[openDialog]
)
return openDialog return confirm
} }

View File

@ -1,12 +1,10 @@
export interface CurrentUser { import type { UserProfile } from 'utils/types'
name: string
email: string
}
export function useCurrentUser(): CurrentUser { export function useCurrentUser(): UserProfile {
// TODO: Implement this hook to get current user that works // TODO: Implement this hook to get current user that works
// in both standalone and embedded mode // in both standalone and embedded mode
return { return {
id: '0',
email: 'admin@harness.io', email: 'admin@harness.io',
name: 'Admin' name: 'Admin'
} }

View File

@ -7,10 +7,41 @@ export enum UserPreference {
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] {
const prefKey = `CODE_MODULE_USER_PREFERENCE_${key}` const prefKey = `CODE_MODULE_USER_PREFERENCE_${key}`
const [preference, setPreference] = useState<T>(localStorage[prefKey] || (defaultValue as T)) const convert = useCallback(
val => {
if (val === undefined || val === null) {
return val
}
if (typeof defaultValue === 'boolean') {
return val === 'true'
}
if (typeof defaultValue === 'number') {
return Number(val)
}
if (Array.isArray(defaultValue) || typeof defaultValue === 'object') {
try {
return JSON.parse(val)
} catch (exception) {
// eslint-disable-next-line no-console
console.error('Failed to parse object', val)
}
}
return val
},
[defaultValue]
)
const [preference, setPreference] = useState<T>(convert(localStorage[prefKey]) || (defaultValue as T))
const savePreference = useCallback( const savePreference = useCallback(
(val: T) => { (val: T) => {
localStorage[prefKey] = val try {
localStorage[prefKey] = JSON.stringify(val)
} catch (exception) {
// eslint-disable-next-line no-console
console.error('Failed to stringify object', val)
}
setPreference(val) setPreference(val)
}, },
[prefKey] [prefKey]

View File

@ -192,3 +192,8 @@ addComment: Add comment
replyHere: Reply here... replyHere: Reply here...
leaveAComment: Leave a comment here... leaveAComment: Leave a comment here...
lineBreaks: Line Breaks lineBreaks: Line Breaks
quote: Quote
deleteCommentConfirm: Are you sure you want to delete this comment?
write: Write
preview: Preview
save: Save

View File

@ -17,16 +17,16 @@ import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import type { DiffFileEntry } from 'utils/types' import type { DiffFileEntry } from 'utils/types'
import { DIFF2HTML_CONFIG, ViewStyle } from 'components/DiffViewer/DiffViewerUtils' import { DIFF2HTML_CONFIG, ViewStyle } from 'components/DiffViewer/DiffViewerUtils'
import { PullRequestTabContentWrapper } from '../PullRequestTabContentWrapper' import { PullRequestTabContentWrapper } from '../PullRequestTabContentWrapper'
import { FilesChangedDropdown } from './FilesChangedDropdown' import { ChangesDropdown } from './ChangesDropdown'
import { DiffViewConfiguration } from './DiffViewConfiguration' import { DiffViewConfiguration } from './DiffViewConfiguration'
import css from './FilesChanged.module.scss' import css from './Changes.module.scss'
import diffExample from 'raw-loader!./example.diff' import diffExample from 'raw-loader!./example.diff'
const STICKY_TOP_POSITION = 64 const STICKY_TOP_POSITION = 64
const STICKY_HEADER_HEIGHT = 150 const STICKY_HEADER_HEIGHT = 150
const diffViewerId = (collection: Unknown[]) => collection.filter(Boolean).join('::::') const diffViewerId = (collection: Unknown[]) => collection.filter(Boolean).join('::::')
export const FilesChanged: React.FC<Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'>> = () => { export const Changes: React.FC<Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'>> = () => {
const { getString } = useStrings() const { getString } = useStrings()
const [viewStyle, setViewStyle] = useUserPreference(UserPreference.DIFF_VIEW_STYLE, ViewStyle.SIDE_BY_SIDE) const [viewStyle, setViewStyle] = useUserPreference(UserPreference.DIFF_VIEW_STYLE, ViewStyle.SIDE_BY_SIDE)
const [lineBreaks, setLineBreaks] = useUserPreference(UserPreference.DIFF_LINE_BREAKS, false) const [lineBreaks, setLineBreaks] = useUserPreference(UserPreference.DIFF_LINE_BREAKS, false)
@ -76,7 +76,7 @@ export const FilesChanged: React.FC<Pick<GitInfoProps, 'repoMetadata' | 'pullReq
<StringSubstitute <StringSubstitute
str={getString('pr.diffStatsLabel')} str={getString('pr.diffStatsLabel')}
vars={{ vars={{
changedFilesLink: <FilesChangedDropdown diffs={diffs} />, changedFilesLink: <ChangesDropdown diffs={diffs} />,
addedLines: formatNumber(diffStats.addedLines), addedLines: formatNumber(diffStats.addedLines),
deletedLines: formatNumber(diffStats.deletedLines), deletedLines: formatNumber(diffStats.deletedLines),
configuration: ( configuration: (

View File

@ -8,12 +8,12 @@ import { CodeIcon } from 'utils/GitUtils'
import { waitUntil } from 'utils/Utils' import { waitUntil } from 'utils/Utils'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator' import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import type { DiffFileEntry } from 'utils/types' import type { DiffFileEntry } from 'utils/types'
import css from './FilesChangedDropdown.module.scss' import css from './ChangesDropdown.module.scss'
// import { TreeExample } from 'pages/Repository/RepositoryTree/TreeExample' // import { TreeExample } from 'pages/Repository/RepositoryTree/TreeExample'
const STICKY_TOP_POSITION = 64 const STICKY_TOP_POSITION = 64
export const FilesChangedDropdown: React.FC<{ diffs: DiffFileEntry[] }> = ({ diffs }) => { export const ChangesDropdown: React.FC<{ diffs: DiffFileEntry[] }> = ({ diffs }) => {
const { getString } = useStrings() const { getString } = useStrings()
return ( return (

View File

@ -8,12 +8,12 @@ import { useStrings } from 'framework/strings'
import { RepositoryPageHeader } from 'components/RepositoryPageHeader/RepositoryPageHeader' import { RepositoryPageHeader } from 'components/RepositoryPageHeader/RepositoryPageHeader'
import { getErrorMessage } from 'utils/Utils' import { getErrorMessage } from 'utils/Utils'
import { CodeIcon } from 'utils/GitUtils' import { CodeIcon } from 'utils/GitUtils'
import type { TypesPullReq } from 'services/code'
import { PullRequestMetaLine } from './PullRequestMetaLine' import { PullRequestMetaLine } from './PullRequestMetaLine'
import { PullRequestConversation } from './PullRequestConversation/PullRequestConversation' import { PullRequestConversation } from './PullRequestConversation/PullRequestConversation'
import { FilesChanged } from './FilesChanged/FilesChanged' import { Changes } from './Changes/Changes'
import { PullRequestCommits } from './PullRequestCommits/PullRequestCommits' import { PullRequestCommits } from './PullRequestCommits/PullRequestCommits'
import css from './PullRequest.module.scss' import css from './PullRequest.module.scss'
import type { TypesPullReq } from 'services/code'
enum PullRequestSection { enum PullRequestSection {
CONVERSATION = 'conversation', CONVERSATION = 'conversation',
@ -97,7 +97,7 @@ export default function PullRequest() {
{ {
id: PullRequestSection.FILES_CHANGED, id: PullRequestSection.FILES_CHANGED,
title: <TabTitle icon={CodeIcon.File} title={getString('filesChanged')} count={20} />, title: <TabTitle icon={CodeIcon.File} title={getString('filesChanged')} count={20} />,
panel: <FilesChanged repoMetadata={repoMetadata} pullRequestMetadata={prData} /> panel: <Changes repoMetadata={repoMetadata} pullRequestMetadata={prData} />
} }
]} ]}
/> />

View File

@ -5,10 +5,10 @@ import ReactTimeago from 'react-timeago'
import { CodeIcon, GitInfoProps, PullRequestState } from 'utils/GitUtils' import { CodeIcon, GitInfoProps, PullRequestState } from 'utils/GitUtils'
import { useAppContext } from 'AppContext' import { useAppContext } from 'AppContext'
import { useStrings } from 'framework/strings' import { useStrings } from 'framework/strings'
import type { TypesPullReq } from 'services/code'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator' import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import { GitRefLink } from 'components/GitRefLink/GitRefLink' import { GitRefLink } from 'components/GitRefLink/GitRefLink'
import css from './PullRequestMetaLine.module.scss' import css from './PullRequestMetaLine.module.scss'
import type { TypesPullReq } from 'services/code'
export const PullRequestMetaLine: React.FC<TypesPullReq & Pick<GitInfoProps, 'repoMetadata'>> = ({ export const PullRequestMetaLine: React.FC<TypesPullReq & Pick<GitInfoProps, 'repoMetadata'>> = ({
repoMetadata, repoMetadata,
@ -26,13 +26,13 @@ export const PullRequestMetaLine: React.FC<TypesPullReq & Pick<GitInfoProps, 're
number: <strong>5</strong>, // TODO: No data from backend now number: <strong>5</strong>, // TODO: No data from backend now
target: ( target: (
<GitRefLink <GitRefLink
text={target_branch!} text={target_branch as string}
url={routes.toCODERepository({ repoPath: repoMetadata.path as string, gitRef: target_branch })} url={routes.toCODERepository({ repoPath: repoMetadata.path as string, gitRef: target_branch })}
/> />
), ),
source: ( source: (
<GitRefLink <GitRefLink
text={source_branch!} text={source_branch as string}
url={routes.toCODERepository({ repoPath: repoMetadata.path as string, gitRef: source_branch })} url={routes.toCODERepository({ repoPath: repoMetadata.path as string, gitRef: source_branch })}
/> />
) )
@ -47,7 +47,7 @@ export const PullRequestMetaLine: React.FC<TypesPullReq & Pick<GitInfoProps, 're
</Text> </Text>
<PipeSeparator height={9} /> <PipeSeparator height={9} />
<Text inline className={cx(css.metaline, css.time)}> <Text inline className={cx(css.metaline, css.time)}>
<ReactTimeago date={updated!} /> <ReactTimeago date={updated as number} />
</Text> </Text>
</Layout.Horizontal> </Layout.Horizontal>
</Container> </Container>

View File

@ -4,3 +4,26 @@ export interface DiffFileEntry extends DiffFile {
containerId: string containerId: string
contentId: string contentId: string
} }
// TODO: Use proper type when API supports it
export interface CommentThreadEntry {
id: string
author: string
created: string
updated: string
content: string
emoji?: EmojiInfo[]
}
// TODO: Use proper type when API supports it
export interface UserProfile {
id: string
name: string
email?: string
}
// TODO: Use proper type when API supports it
export interface EmojiInfo {
name: string
by: UserProfile[]
}