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 { 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 { className?: string getString: UseStringsReturn['getString'] onHeightChange?: (height: number) => void initialContent?: string width?: string fluid?: boolean resetOnSave?: boolean hideCancel?: boolean currentUserName: string commentItems: CommentItem[] handleAction: ( action: CommentAction, content: string, atCommentItem?: CommentItem ) => Promise<[boolean, CommentItem | undefined]> onCancel?: () => void outlets?: Partial> } export const CommentBox = ({ className, getString, onHeightChange = noop, initialContent = '', width, fluid, commentItems = [], currentUserName, handleAction, onCancel = noop, hideCancel, resetOnSave, outlets = {} }: CommentBoxProps) => { const [comments, setComments] = useState[]>(commentItems) const [showReplyPlaceHolder, setShowReplyPlaceHolder] = useState(!!comments.length) const [markdown, setMarkdown] = useState(initialContent) const { ref } = useResizeDetector({ 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() return ( {outlets[CommentBoxOutletPosition.TOP]} 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} /> } 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]) setShowReplyPlaceHolder(true) } } } else { alert('handleAction must be implemented...') } }} onCancel={_onCancel} hideCancel={hideCancel} /> ) } interface CommentsThreadProps extends Pick, 'commentItems' | 'getString' | 'handleAction' | 'outlets'> { onQuote: (content: string) => void } const CommentsThread = ({ getString, onQuote, commentItems = [], handleAction, outlets = {} }: CommentsThreadProps) => { const { standalone } = useAppContext() const [editIndexes, setEditIndexes] = useState>({}) const resetStateAtIndex = useCallback( (index: number) => { delete editIndexes[index] setEditIndexes({ ...editIndexes }) }, [editIndexes] ) return ( {commentItems.map((commentItem, index) => { const isLastItem = index === commentItems.length - 1 return ( {commentItem?.author} <> {getString(commentItem?.deleted ? 'deleted' : 'edited')} 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) } } } ]} /> } hideGutter={isLastItem}> {outlets[CommentBoxOutletPosition.TOP_OF_FIRST_COMMENT]} { 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') }} /> {getString('commentDeleted')} ) })} ) } const CRLF = '\n'