drone/web/src/components/CommentBox/CommentBox.tsx
Tan Nhu a2cac1e82b API integration for different type of merges (#268)
Co-authored-by: Tan Nhu <tnhu@users.noreply.github.com>
2023-01-31 08:41:46 -08:00

332 lines
12 KiB
TypeScript

import React, { useCallback, useRef, useState } from 'react'
import { useResizeDetector } from 'react-resize-detector'
import { Render, Match, Truthy, Falsy, Else } from 'react-jsx-match'
import { Container, Layout, Avatar, TextInput, Text, Color, FontVariation, FlexExpander } from '@harness/uicore'
import cx from 'classnames'
import MarkdownEditor from '@uiw/react-markdown-editor'
import ReactTimeago from 'react-timeago'
import { noop } from 'lodash-es'
import type { UseStringsReturn } from 'framework/strings'
import { ThreadSection } from 'components/ThreadSection/ThreadSection'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import { useAppContext } from 'AppContext'
import { OptionsMenuButton } from 'components/OptionsMenuButton/OptionsMenuButton'
import {
MarkdownEditorWithPreview,
MarkdownEditorWithPreviewResetProps
} from 'components/MarkdownEditorWithPreview/MarkdownEditorWithPreview'
import css from './CommentBox.module.scss'
export interface CommentItem<T = unknown> {
author: string
created: string | number
updated: string | number
deleted: string | number
content: string
payload?: T // optional payload for callers to handle on callback calls
}
export enum CommentAction {
NEW = 'new',
UPDATE = 'update',
REPLY = 'reply',
DELETE = 'delete'
}
// Outlets are used to insert additional components into CommentBox
export enum CommentBoxOutletPosition {
TOP = 'top',
BOTTOM = 'bottom',
TOP_OF_FIRST_COMMENT = 'top_of_first_comment',
BOTTOM_OF_COMMENT_EDITOR = 'bottom_of_comment_editor'
}
interface CommentBoxProps<T> {
className?: string
getString: UseStringsReturn['getString']
onHeightChange?: (height: number) => void
initialContent?: string
width?: string
fluid?: boolean
resetOnSave?: boolean
hideCancel?: boolean
currentUserName: string
commentItems: CommentItem<T>[]
handleAction: (
action: CommentAction,
content: string,
atCommentItem?: CommentItem<T>
) => Promise<[boolean, CommentItem<T> | undefined]>
onCancel?: () => void
outlets?: Partial<Record<CommentBoxOutletPosition, React.ReactNode>>
}
export const CommentBox = <T = unknown,>({
className,
getString,
onHeightChange = noop,
initialContent = '',
width,
fluid,
commentItems = [],
currentUserName,
handleAction,
onCancel = noop,
hideCancel,
resetOnSave,
outlets = {}
}: CommentBoxProps<T>) => {
const [comments, setComments] = useState<CommentItem<T>[]>(commentItems)
const [showReplyPlaceHolder, setShowReplyPlaceHolder] = useState(!!comments.length)
const [markdown, setMarkdown] = useState(initialContent)
const { ref } = useResizeDetector<HTMLDivElement>({
refreshMode: 'debounce',
handleWidth: false,
refreshRate: 50,
observerOptions: { box: 'border-box' },
onResize: () => onHeightChange(ref.current?.offsetHeight as number)
})
const _onCancel = useCallback(() => {
setMarkdown('')
if (!comments.length) {
onCancel()
} else {
setShowReplyPlaceHolder(true)
}
}, [comments, setShowReplyPlaceHolder, onCancel])
const hidePlaceHolder = useCallback(() => setShowReplyPlaceHolder(false), [setShowReplyPlaceHolder])
const onQuote = useCallback((content: string) => {
setShowReplyPlaceHolder(false)
setMarkdown(
content
.split(CRLF)
.map(line => `> ${line}`)
.concat([CRLF, CRLF])
.join(CRLF)
)
}, [])
const editorRef = useRef<MarkdownEditorWithPreviewResetProps>()
return (
<Container
className={cx(css.main, { [css.fluid]: fluid }, className)}
padding={!fluid ? 'medium' : undefined}
width={width}
ref={ref}>
<Container className={css.box}>
{outlets[CommentBoxOutletPosition.TOP]}
<Layout.Vertical>
<CommentsThread<T>
commentItems={comments}
getString={getString}
onQuote={onQuote}
handleAction={async (action, content, atCommentItem) => {
const [result, updatedItem] = await handleAction(action, content, atCommentItem)
if (result && action === CommentAction.DELETE && atCommentItem) {
atCommentItem.updated = atCommentItem.deleted = Date.now()
setComments([...comments])
}
return [result, updatedItem]
}}
outlets={outlets}
/>
<Match expr={showReplyPlaceHolder}>
<Truthy>
<Container>
<Layout.Horizontal spacing="small" className={css.replyPlaceHolder} padding="medium">
<Avatar name={currentUserName} size="small" hoverCard={false} />
<TextInput placeholder={getString('replyHere')} onFocus={hidePlaceHolder} onClick={hidePlaceHolder} />
</Layout.Horizontal>
</Container>
</Truthy>
<Falsy>
<Container
padding="xlarge"
className={cx(css.newCommentContainer, { [css.hasThread]: !!comments.length })}>
<MarkdownEditorWithPreview
editorRef={editorRef as React.MutableRefObject<MarkdownEditorWithPreviewResetProps>}
i18n={{
placeHolder: getString(comments.length ? 'replyHere' : 'leaveAComment'),
tabEdit: getString('write'),
tabPreview: getString('preview'),
save: getString('addComment'),
cancel: getString('cancel')
}}
value={markdown}
onChange={setMarkdown}
onSave={async (value: string) => {
if (handleAction) {
const [result, updatedItem] = await handleAction(
comments.length ? CommentAction.REPLY : CommentAction.NEW,
value,
comments[0]
)
if (result) {
setMarkdown('')
if (resetOnSave) {
editorRef.current?.resetEditor?.()
} else {
setComments([...comments, updatedItem as CommentItem<T>])
setShowReplyPlaceHolder(true)
}
}
} else {
alert('handleAction must be implemented...')
}
}}
onCancel={_onCancel}
hideCancel={hideCancel}
/>
</Container>
</Falsy>
</Match>
</Layout.Vertical>
</Container>
</Container>
)
}
interface CommentsThreadProps<T>
extends Pick<CommentBoxProps<T>, 'commentItems' | 'getString' | 'handleAction' | 'outlets'> {
onQuote: (content: string) => void
}
const CommentsThread = <T = unknown,>({
getString,
onQuote,
commentItems = [],
handleAction,
outlets = {}
}: CommentsThreadProps<T>) => {
const { standalone } = useAppContext()
const [editIndexes, setEditIndexes] = useState<Record<number, boolean>>({})
const resetStateAtIndex = useCallback(
(index: number) => {
delete editIndexes[index]
setEditIndexes({ ...editIndexes })
},
[editIndexes]
)
return (
<Render when={commentItems.length}>
<Container className={css.viewer} padding="xlarge">
{commentItems.map((commentItem, index) => {
const isLastItem = index === commentItems.length - 1
return (
<ThreadSection
key={index}
title={
<Layout.Horizontal spacing="small" style={{ alignItems: 'center' }}>
<Text inline icon="code-chat"></Text>
<Avatar name={commentItem?.author} size="small" hoverCard={false} />
<Text inline>
<strong>{commentItem?.author}</strong>
</Text>
<PipeSeparator height={8} />
<Text inline font={{ variation: FontVariation.SMALL }} color={Color.GREY_400}>
<ReactTimeago date={new Date(commentItem?.updated)} />
</Text>
<Render when={commentItem?.updated !== commentItem?.created || !!commentItem?.deleted}>
<>
<PipeSeparator height={8} />
<Text inline font={{ variation: FontVariation.SMALL }} color={Color.GREY_400}>
{getString(commentItem?.deleted ? 'deleted' : 'edited')}
</Text>
</>
</Render>
<FlexExpander />
<OptionsMenuButton
isDark={false}
icon="Options"
iconProps={{ size: 14 }}
style={{ padding: '5px' }}
disabled={!!commentItem?.deleted}
width="100px"
items={[
{
text: getString('edit'),
onClick: () => setEditIndexes({ ...editIndexes, ...{ [index]: true } })
},
{
text: getString('quote'),
onClick: () => onQuote(commentItem?.content)
},
'-',
{
isDanger: true,
text: getString('delete'),
onClick: async () => {
if (await handleAction(CommentAction.DELETE, '', commentItem)) {
resetStateAtIndex(index)
}
}
}
]}
/>
</Layout.Horizontal>
}
hideGutter={isLastItem}>
<Container
padding={{
left: editIndexes[index] ? undefined : 'medium',
bottom: isLastItem ? undefined : 'xsmall'
}}>
<Render when={index === 0 && outlets[CommentBoxOutletPosition.TOP_OF_FIRST_COMMENT]}>
<Container className={cx(css.outletTopOfFirstOfComment, { [css.standalone]: standalone })}>
{outlets[CommentBoxOutletPosition.TOP_OF_FIRST_COMMENT]}
</Container>
</Render>
<Match expr={editIndexes[index]}>
<Truthy>
<Container className={css.editCommentContainer}>
<MarkdownEditorWithPreview
value={commentItem.content}
onSave={async value => {
if (await handleAction(CommentAction.UPDATE, value, commentItem)) {
commentItem.content = value
resetStateAtIndex(index)
}
}}
onCancel={() => resetStateAtIndex(index)}
i18n={{
placeHolder: getString('leaveAComment'),
tabEdit: getString('write'),
tabPreview: getString('preview'),
save: getString('save'),
cancel: getString('cancel')
}}
/>
</Container>
</Truthy>
<Else>
<Match expr={commentItem?.deleted}>
<Truthy>
<Text className={css.deleted}>{getString('commentDeleted')}</Text>
</Truthy>
<Else>
<MarkdownEditor.Markdown source={commentItem?.content} />
</Else>
</Match>
</Else>
</Match>
</Container>
</ThreadSection>
)
})}
</Container>
</Render>
)
}
const CRLF = '\n'