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 boxLayout: string
readonly viewer: string
readonly editor: string
readonly markdownEditor: string
readonly preview: string
readonly replyPlaceHolder: string
readonly deleteMenuItem: string
readonly newCommentContainer: string
readonly editCommentContainer: string
}
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 ReactDOM from 'react-dom'
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 'highlight.js/styles/github.css'
import 'diff2html/bundles/css/diff2html.min.css'
import { noop } from 'lodash-es'
import { useStrings } from 'framework/strings'
import { CodeIcon } from 'utils/GitUtils'
import { useEventListener } from 'hooks/useEventListener'
import type { DiffFileEntry } from 'utils/types'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import { useCurrentUser } from 'hooks/useCurrentUser'
import { useConfirmAction } from 'hooks/useConfirmAction'
import {
CommentItem,
DIFF2HTML_CONFIG,
@ -19,7 +31,7 @@ import {
renderCommentOppositePlaceHolder,
ViewStyle
} from './DiffViewerUtils'
import { CommentBox } from './CommentBox/CommentBox'
import { CommentBox } from '../CommentBox/CommentBox'
import css from './DiffViewer.module.scss'
interface DiffViewerProps {
@ -46,6 +58,17 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({ diff, index, viewStyle,
const { ref: inViewRef, inView } = useInView({ rootMargin: '100px 0px' })
const containerRef = useRef<HTMLDivElement | null>(null)
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[]>(
!index
? [
@ -54,13 +77,19 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({ diff, index, viewStyle,
right: true,
height: 0,
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\``,
// `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.`,
// `* 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).`
]
].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,
lineNumber: 0,
height: 0,
contents: []
commentsThread: []
}
if (targetButton && annotatedLineRow) {
@ -244,18 +273,14 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({ diff, index, viewStyle,
ReactDOM.unmountComponentAtNode(element as HTMLDivElement)
ReactDOM.render(
<CommentBox
contents={comment.contents}
commentsThread={comment.commentsThread}
getString={getString}
width={isSideBySide ? 'calc(100vw / 2 - 163px)' : undefined}
onHeightChange={boxHeight => {
if (typeof boxHeight === 'string') {
element.style.height = boxHeight
} else {
if (comment.height !== boxHeight) {
comment.height = boxHeight
element.style.height = `${boxHeight}px`
setTimeout(() => setComments([...commentsRef.current]), 0)
}
if (comment.height !== boxHeight) {
comment.height = boxHeight
// element.style.height = `${boxHeight}px`
setTimeout(() => setComments([...commentsRef.current]), 0)
}
}}
onCancel={() => {
@ -269,6 +294,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({ diff, index, viewStyle,
setTimeout(() => setComments(commentsRef.current.filter(item => item !== comment)), 0)
}}
currentUser={currentUser}
executeDeleteComent={executeDeleteComentConfirmation}
/>,
element
)
@ -278,13 +304,14 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({ diff, index, viewStyle,
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() {

View File

@ -2,6 +2,7 @@ import type * as Diff2Html from 'diff2html'
import HoganJsUtils from 'diff2html/lib/hoganjs-utils'
import 'highlight.js/styles/github.css'
import 'diff2html/bundles/css/diff2html.min.css'
import type { CommentThreadEntry } from 'utils/types'
export enum ViewStyle {
SIDE_BY_SIDE = 'side-by-side',
@ -17,7 +18,7 @@ export interface CommentItem {
right: boolean
lineNumber: number
height: number
contents: string[]
commentsThread: CommentThreadEntry[]
}
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 {
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 (
<Button
minimal
icon="code-more"
tooltipProps={{ isDark: true, interactionKind: 'click', hasBackdrop: true } as PopoverProps}
icon={icon}
tooltipProps={{ isDark, interactionKind: 'click', hasBackdrop: true } as PopoverProps}
tooltip={
<Menu style={{ minWidth: 'unset' }}>
{items.map(

View File

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

View File

@ -6,6 +6,7 @@
*/
import { Intent } from '@blueprintjs/core'
import { useCallback, useState } from 'react'
import { useConfirmationDialog } from '@harness/uicore'
import { useStrings } from 'framework/strings'
@ -15,12 +16,13 @@ export interface UseConfirmActionDialogProps {
title?: string
confirmText?: string
cancelText?: string
action: () => void
action: (params?: Unknown) => void
}
export const useConfirmAction = (props: UseConfirmActionDialogProps) => {
const { title, message, confirmText, cancelText, intent, action } = props
const { getString } = useStrings()
const [params, setParams] = useState<Unknown>()
const { openDialog } = useConfirmationDialog({
intent,
titleText: title || getString('confirmation'),
@ -30,10 +32,17 @@ export const useConfirmAction = (props: UseConfirmActionDialogProps) => {
buttonIntent: intent || Intent.DANGER,
onCloseDialog: async (isConfirmed: boolean) => {
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 {
name: string
email: string
}
import type { UserProfile } from 'utils/types'
export function useCurrentUser(): CurrentUser {
export function useCurrentUser(): UserProfile {
// TODO: Implement this hook to get current user that works
// in both standalone and embedded mode
return {
id: '0',
email: 'admin@harness.io',
name: 'Admin'
}

View File

@ -7,10 +7,41 @@ export enum UserPreference {
export function useUserPreference<T = string>(key: UserPreference, defaultValue: T): [T, (val: T) => void] {
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(
(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)
},
[prefKey]

View File

@ -192,3 +192,8 @@ addComment: Add comment
replyHere: Reply here...
leaveAComment: Leave a comment here...
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 { DIFF2HTML_CONFIG, ViewStyle } from 'components/DiffViewer/DiffViewerUtils'
import { PullRequestTabContentWrapper } from '../PullRequestTabContentWrapper'
import { FilesChangedDropdown } from './FilesChangedDropdown'
import { ChangesDropdown } from './ChangesDropdown'
import { DiffViewConfiguration } from './DiffViewConfiguration'
import css from './FilesChanged.module.scss'
import css from './Changes.module.scss'
import diffExample from 'raw-loader!./example.diff'
const STICKY_TOP_POSITION = 64
const STICKY_HEADER_HEIGHT = 150
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 [viewStyle, setViewStyle] = useUserPreference(UserPreference.DIFF_VIEW_STYLE, ViewStyle.SIDE_BY_SIDE)
const [lineBreaks, setLineBreaks] = useUserPreference(UserPreference.DIFF_LINE_BREAKS, false)
@ -76,7 +76,7 @@ export const FilesChanged: React.FC<Pick<GitInfoProps, 'repoMetadata' | 'pullReq
<StringSubstitute
str={getString('pr.diffStatsLabel')}
vars={{
changedFilesLink: <FilesChangedDropdown diffs={diffs} />,
changedFilesLink: <ChangesDropdown diffs={diffs} />,
addedLines: formatNumber(diffStats.addedLines),
deletedLines: formatNumber(diffStats.deletedLines),
configuration: (

View File

@ -8,12 +8,12 @@ import { CodeIcon } from 'utils/GitUtils'
import { waitUntil } from 'utils/Utils'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
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'
const STICKY_TOP_POSITION = 64
export const FilesChangedDropdown: React.FC<{ diffs: DiffFileEntry[] }> = ({ diffs }) => {
export const ChangesDropdown: React.FC<{ diffs: DiffFileEntry[] }> = ({ diffs }) => {
const { getString } = useStrings()
return (

View File

@ -8,12 +8,12 @@ import { useStrings } from 'framework/strings'
import { RepositoryPageHeader } from 'components/RepositoryPageHeader/RepositoryPageHeader'
import { getErrorMessage } from 'utils/Utils'
import { CodeIcon } from 'utils/GitUtils'
import type { TypesPullReq } from 'services/code'
import { PullRequestMetaLine } from './PullRequestMetaLine'
import { PullRequestConversation } from './PullRequestConversation/PullRequestConversation'
import { FilesChanged } from './FilesChanged/FilesChanged'
import { Changes } from './Changes/Changes'
import { PullRequestCommits } from './PullRequestCommits/PullRequestCommits'
import css from './PullRequest.module.scss'
import type { TypesPullReq } from 'services/code'
enum PullRequestSection {
CONVERSATION = 'conversation',
@ -97,7 +97,7 @@ export default function PullRequest() {
{
id: PullRequestSection.FILES_CHANGED,
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 { useAppContext } from 'AppContext'
import { useStrings } from 'framework/strings'
import type { TypesPullReq } from 'services/code'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import { GitRefLink } from 'components/GitRefLink/GitRefLink'
import css from './PullRequestMetaLine.module.scss'
import type { TypesPullReq } from 'services/code'
export const PullRequestMetaLine: React.FC<TypesPullReq & Pick<GitInfoProps, '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
target: (
<GitRefLink
text={target_branch!}
text={target_branch as string}
url={routes.toCODERepository({ repoPath: repoMetadata.path as string, gitRef: target_branch })}
/>
),
source: (
<GitRefLink
text={source_branch!}
text={source_branch as string}
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>
<PipeSeparator height={9} />
<Text inline className={cx(css.metaline, css.time)}>
<ReactTimeago date={updated!} />
<ReactTimeago date={updated as number} />
</Text>
</Layout.Horizontal>
</Container>

View File

@ -4,3 +4,26 @@ export interface DiffFileEntry extends DiffFile {
containerId: 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[]
}