Merge branch 'ui/editor-dirty-check' of _OKE5H2PQKOUfzFFDuD4FA/default/CODE/gitness (#48)

This commit is contained in:
Tan Nhu 2023-05-01 23:32:14 +00:00 committed by Harness
commit db0fcbcde4
12 changed files with 256 additions and 122 deletions

View File

@ -1,124 +1,127 @@
import React, { useCallback, useEffect, useState } from 'react'
import { Radio, RadioGroup, ButtonVariation, Button, Container, Layout, ButtonSize, useToaster } from '@harness/uicore'
import { Render } from 'react-jsx-match'
import { useMutate } from 'restful-react'
import cx from 'classnames'
import { useStrings } from 'framework/strings'
import type { GitInfoProps } from 'utils/GitUtils'
import type { TypesPullReq } from 'services/code'
import { getErrorMessage } from 'utils/Utils'
import { MarkdownEditorWithPreview } from 'components/MarkdownEditorWithPreview/MarkdownEditorWithPreview'
import css from './ReviewDecisionButton.module.scss'
// import React, { useCallback, useEffect, useState } from 'react'
// import { Radio, RadioGroup, ButtonVariation, Button, Container, Layout, ButtonSize, useToaster } from '@harness/uicore'
// import { Render } from 'react-jsx-match'
// import { useMutate } from 'restful-react'
// import cx from 'classnames'
// import { useStrings } from 'framework/strings'
// import type { GitInfoProps } from 'utils/GitUtils'
// import type { TypesPullReq } from 'services/code'
// import { getErrorMessage } from 'utils/Utils'
// import { MarkdownEditorWithPreview } from 'components/MarkdownEditorWithPreview/MarkdownEditorWithPreview'
// import css from './ReviewDecisionButton.module.scss'
enum PullReqReviewDecision {
REVIEWED = 'reviewed',
APPROVED = 'approved',
CHANGEREQ = 'changereq'
}
// enum PullReqReviewDecision {
// REVIEWED = 'reviewed',
// APPROVED = 'approved',
// CHANGEREQ = 'changereq'
// }
interface ReviewDecisionButtonProps extends Pick<GitInfoProps, 'repoMetadata'> {
shouldHide: boolean
pullRequestMetadata?: TypesPullReq
}
// interface ReviewDecisionButtonProps extends Pick<GitInfoProps, 'repoMetadata'> {
// shouldHide: boolean
// pullRequestMetadata?: TypesPullReq
// }
export const ReviewDecisionButton: React.FC<ReviewDecisionButtonProps> = ({
repoMetadata,
pullRequestMetadata,
shouldHide
}) => {
const { getString } = useStrings()
const { showError, showSuccess } = useToaster()
const [comment, setComment] = useState('')
const [reset, setReset] = useState(false)
const [decision, setDecision] = useState<PullReqReviewDecision>(PullReqReviewDecision.REVIEWED)
const { mutate, loading } = useMutate({
verb: 'POST',
path: `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata?.number}/reviews`
})
const submitReview = useCallback(() => {
mutate({ decision, message: comment })
.then(() => {
setReset(true)
showSuccess(getString('pr.reviewSubmitted'))
})
.catch(exception => showError(getErrorMessage(exception)))
}, [comment, decision, mutate, showError, showSuccess, getString])
// /**
// * @deprecated
// */
// export const ReviewDecisionButton: React.FC<ReviewDecisionButtonProps> = ({
// repoMetadata,
// pullRequestMetadata,
// shouldHide
// }) => {
// const { getString } = useStrings()
// const { showError, showSuccess } = useToaster()
// const [comment, setComment] = useState('')
// const [reset, setReset] = useState(false)
// const [decision, setDecision] = useState<PullReqReviewDecision>(PullReqReviewDecision.REVIEWED)
// const { mutate, loading } = useMutate({
// verb: 'POST',
// path: `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata?.number}/reviews`
// })
// const submitReview = useCallback(() => {
// mutate({ decision, message: comment })
// .then(() => {
// setReset(true)
// showSuccess(getString('pr.reviewSubmitted'))
// })
// .catch(exception => showError(getErrorMessage(exception)))
// }, [comment, decision, mutate, showError, showSuccess, getString])
useEffect(() => {
let timeoutId = 0
if (reset) {
timeoutId = window.setTimeout(() => setReset(false))
}
return () => window.clearTimeout(timeoutId)
}, [reset])
// useEffect(() => {
// let timeoutId = 0
// if (reset) {
// timeoutId = window.setTimeout(() => setReset(false))
// }
// return () => window.clearTimeout(timeoutId)
// }, [reset])
return (
<Button
text={getString('pr.reviewChanges')}
variation={ButtonVariation.PRIMARY}
intent="success"
rightIcon="chevron-down"
className={cx(css.btn, { [css.hide]: shouldHide })}
style={{ '--background-color': 'var(--green-800)' } as React.CSSProperties}
tooltip={
<Render when={!reset}>
<Container padding="large" className={css.popup}>
<Layout.Vertical spacing="medium">
<Container className={css.markdown} padding="medium">
<MarkdownEditorWithPreview
value={comment}
onChange={setComment}
hideButtons
i18n={{
placeHolder: getString('leaveAComment'),
tabEdit: getString('write'),
tabPreview: getString('preview'),
save: getString('save'),
cancel: getString('cancel')
}}
editorHeight="100px"
/>
</Container>
<Container padding={{ left: 'xxxlarge' }}>
<RadioGroup>
<Radio
name="decision"
defaultChecked={decision === PullReqReviewDecision.REVIEWED}
label={getString('comment')}
value={PullReqReviewDecision.REVIEWED}
onChange={() => setDecision(PullReqReviewDecision.REVIEWED)}
/>
<Radio
name="decision"
defaultChecked={decision === PullReqReviewDecision.APPROVED}
label={getString('approve')}
value={PullReqReviewDecision.APPROVED}
onChange={() => setDecision(PullReqReviewDecision.APPROVED)}
/>
<Radio
name="decision"
defaultChecked={decision === PullReqReviewDecision.CHANGEREQ}
label={getString('requestChanges')}
value={PullReqReviewDecision.CHANGEREQ}
onChange={() => setDecision(PullReqReviewDecision.CHANGEREQ)}
/>
</RadioGroup>
</Container>
<Container>
<Button
variation={ButtonVariation.PRIMARY}
text={getString('submitReview')}
size={ButtonSize.MEDIUM}
onClick={submitReview}
disabled={!(comment || '').trim().length && decision != PullReqReviewDecision.APPROVED}
loading={loading}
/>
</Container>
</Layout.Vertical>
</Container>
</Render>
}
tooltipProps={{ interactionKind: 'click', position: 'bottom-right', hasBackdrop: true }}
/>
)
}
// return (
// <Button
// text={getString('pr.reviewChanges')}
// variation={ButtonVariation.PRIMARY}
// intent="success"
// rightIcon="chevron-down"
// className={cx(css.btn, { [css.hide]: shouldHide })}
// style={{ '--background-color': 'var(--green-800)' } as React.CSSProperties}
// tooltip={
// <Render when={!reset}>
// <Container padding="large" className={css.popup}>
// <Layout.Vertical spacing="medium">
// <Container className={css.markdown} padding="medium">
// <MarkdownEditorWithPreview
// value={comment}
// onChange={setComment}
// hideButtons
// i18n={{
// placeHolder: getString('leaveAComment'),
// tabEdit: getString('write'),
// tabPreview: getString('preview'),
// save: getString('save'),
// cancel: getString('cancel')
// }}
// editorHeight="100px"
// />
// </Container>
// <Container padding={{ left: 'xxxlarge' }}>
// <RadioGroup>
// <Radio
// name="decision"
// defaultChecked={decision === PullReqReviewDecision.REVIEWED}
// label={getString('comment')}
// value={PullReqReviewDecision.REVIEWED}
// onChange={() => setDecision(PullReqReviewDecision.REVIEWED)}
// />
// <Radio
// name="decision"
// defaultChecked={decision === PullReqReviewDecision.APPROVED}
// label={getString('approve')}
// value={PullReqReviewDecision.APPROVED}
// onChange={() => setDecision(PullReqReviewDecision.APPROVED)}
// />
// <Radio
// name="decision"
// defaultChecked={decision === PullReqReviewDecision.CHANGEREQ}
// label={getString('requestChanges')}
// value={PullReqReviewDecision.CHANGEREQ}
// onChange={() => setDecision(PullReqReviewDecision.CHANGEREQ)}
// />
// </RadioGroup>
// </Container>
// <Container>
// <Button
// variation={ButtonVariation.PRIMARY}
// text={getString('submitReview')}
// size={ButtonSize.MEDIUM}
// onClick={submitReview}
// disabled={!(comment || '').trim().length && decision != PullReqReviewDecision.APPROVED}
// loading={loading}
// />
// </Container>
// </Layout.Vertical>
// </Container>
// </Render>
// }
// tooltipProps={{ interactionKind: 'click', position: 'bottom-right', hasBackdrop: true }}
// />
// )
// }

View File

@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useResizeDetector } from 'react-resize-detector'
import type { EditorView } from '@codemirror/view'
import { Render, Match, Truthy, Falsy, Else } from 'react-jsx-match'
@ -68,6 +68,7 @@ interface CommentBoxProps<T> {
atCommentItem?: CommentItem<T>
) => Promise<[boolean, CommentItem<T> | undefined]>
onCancel?: () => void
setDirty: (dirty: boolean) => void
outlets?: Partial<Record<CommentBoxOutletPosition, React.ReactNode>>
}
@ -83,12 +84,14 @@ export const CommentBox = <T = unknown,>({
onCancel = noop,
hideCancel,
resetOnSave,
setDirty: setDirtyProp,
outlets = {}
}: CommentBoxProps<T>) => {
const { getString } = useStrings()
const [comments, setComments] = useState<CommentItem<T>[]>(commentItems)
const [showReplyPlaceHolder, setShowReplyPlaceHolder] = useState(!!comments.length)
const [markdown, setMarkdown] = useState(initialContent)
const [dirties, setDirties] = useState<Record<string, boolean>>({})
const { ref } = useResizeDetector<HTMLDivElement>({
refreshMode: 'debounce',
handleWidth: false,
@ -117,6 +120,13 @@ export const CommentBox = <T = unknown,>({
}, [])
const viewRef = useRef<EditorView>()
useEffect(() => {
setDirtyProp(Object.values(dirties).some(dirty => dirty))
return () => {
setDirtyProp(false)
}
}, [dirties]) // eslint-disable-line react-hooks/exhaustive-deps
return (
<Container
className={cx(css.main, { [css.fluid]: fluid }, className)}
@ -140,6 +150,9 @@ export const CommentBox = <T = unknown,>({
return [result, updatedItem]
}}
setDirty={(index, dirty) => {
setDirties({ ...dirties, [index]: dirty })
}}
outlets={outlets}
/>
<Match expr={showReplyPlaceHolder}>
@ -199,6 +212,9 @@ export const CommentBox = <T = unknown,>({
}}
onCancel={_onCancel}
hideCancel={hideCancel}
setDirty={_dirty => {
setDirties({ ...dirties, ['new']: _dirty })
}}
/>
</Container>
</Falsy>
@ -211,12 +227,14 @@ export const CommentBox = <T = unknown,>({
interface CommentsThreadProps<T> extends Pick<CommentBoxProps<T>, 'commentItems' | 'handleAction' | 'outlets'> {
onQuote: (content: string) => void
setDirty: (index: number, dirty: boolean) => void
}
const CommentsThread = <T = unknown,>({
onQuote,
commentItems = [],
handleAction,
setDirty,
outlets = {}
}: CommentsThreadProps<T>) => {
const { getString } = useStrings()
@ -331,6 +349,9 @@ const CommentsThread = <T = unknown,>({
}
}}
onCancel={() => resetStateAtIndex(index)}
setDirty={_dirty => {
setDirty(index, _dirty)
}}
i18n={{
placeHolder: getString('leaveAComment'),
tabEdit: getString('write'),

View File

@ -28,6 +28,7 @@ import type { OpenapiCommentCreatePullReqRequest, TypesPullReq, TypesPullReqActi
import { getErrorMessage } from 'utils/Utils'
import { CopyButton } from 'components/CopyButton/CopyButton'
import { AppWrapper } from 'App'
import { NavigationCheck } from 'components/NavigationCheck/NavigationCheck'
import {
activitiesToDiffCommentItems,
activityToCommentItem,
@ -91,6 +92,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
const { mutate: updateComment } = useMutate({ verb: 'PATCH', path: ({ id }) => `${path}/${id}` })
const { mutate: deleteComment } = useMutate({ verb: 'DELETE', path: ({ id }) => `${path}/${id}` })
const [comments, setComments] = useState<DiffCommentItem<TypesPullReqActivity>[]>(activitiesToDiffCommentItems(diff))
const [dirty, setDirty] = useState(false)
const commentsRef = useRef<DiffCommentItem<TypesPullReqActivity>[]>(comments)
const setContainerRef = useCallback(
node => {
@ -298,6 +300,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
delete lineInfo.rowElement.dataset.annotated
setTimeout(() => setComments(commentsRef.current.filter(item => item !== comment)), 0)
}}
setDirty={setDirty}
currentUserName={currentUser.display_name}
handleAction={async (action, value, commentItem) => {
let result = true
@ -511,6 +514,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
</Render>
</Container>
</Layout.Vertical>
<NavigationCheck when={dirty} />
</Container>
)
}

View File

@ -40,6 +40,7 @@ interface MarkdownEditorWithPreviewProps {
onChange?: (value: string) => void
onSave?: (value: string) => void
onCancel?: () => void
setDirty: (dirty: boolean) => void
i18n: {
placeHolder: string
tabEdit: string
@ -59,6 +60,7 @@ export function MarkdownEditorWithPreview({
onChange,
onSave,
onCancel,
setDirty: setDirtyProp,
i18n,
hideButtons,
hideCancel,
@ -186,6 +188,14 @@ export function MarkdownEditorWithPreview({
}
}, [])
useEffect(() => {
setDirtyProp?.(dirty)
return () => {
setDirtyProp?.(false)
}
}, [dirty]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (viewRefProp) {
viewRefProp.current = viewRef.current

View File

@ -0,0 +1,7 @@
.main {
padding-top: var(--spacing-xxlarge) !important;
p {
word-break: normal;
}
}

View File

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

View File

@ -0,0 +1,56 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Prompt, useHistory } from 'react-router-dom'
import { useConfirmationDialog } from '@harness/uicore'
import { Intent } from '@harness/design-system'
import type * as History from 'history'
import { useStrings } from 'framework/strings'
import css from './NavigationCheck.module.scss'
interface NavigationCheckProps {
when?: boolean
i18n?: {
message?: string
title?: string
confirmText?: string
cancelText?: string
}
replace?: boolean
}
export const NavigationCheck: React.FC<NavigationCheckProps> = ({ when, i18n, replace }) => {
const history = useHistory()
const [lastLocation, setLastLocation] = useState<History.Location | null>(null)
const [confirmed, setConfirmed] = useState(false)
const { getString } = useStrings()
const { openDialog } = useConfirmationDialog({
cancelButtonText: i18n?.cancelText || getString('unsavedChanges.stay'),
showCloseButton: false,
contentText: i18n?.message || getString('unsavedChanges.message'),
titleText: i18n?.title || getString('unsavedChanges.title'),
confirmButtonText: i18n?.confirmText || getString('unsavedChanges.leave'),
intent: Intent.WARNING,
onCloseDialog: setConfirmed,
className: css.main
})
const prompt = useCallback(
(nextLocation: History.Location): string | boolean => {
if (!confirmed) {
openDialog()
setLastLocation(nextLocation)
return false
}
return true
},
[confirmed, openDialog]
)
useEffect(() => {
if (confirmed && lastLocation) {
history[replace ? 'replace' : 'push'](lastLocation.pathname + lastLocation.search)
}
confirmed && setConfirmed(false)
}, [confirmed, lastLocation, history, replace])
return <Prompt when={when} message={prompt} />
}

View File

@ -302,6 +302,10 @@ export interface StringsMap {
tags: string
title: string
tooltipRepoEdit: string
'unsavedChanges.leave': string
'unsavedChanges.message': string
'unsavedChanges.stay': string
'unsavedChanges.title': string
updateFile: string
updateWebhook: string
updated: string

View File

@ -377,3 +377,8 @@ viewRaw: View Raw
download: Download
changes: Changes
contents: Contents
unsavedChanges:
title: Close without saving?
message: 'You have unsaved changes. Are you sure you want to leave this page without saving?'
leave: Leave this Page
stay: Stay on this Page

View File

@ -33,6 +33,7 @@ import { CommentAction, CommentBox, CommentBoxOutletPosition, CommentItem } from
import { useConfirmAct } from 'hooks/useConfirmAction'
import { commentState, formatDate, formatTime, getErrorMessage, orderSortDate, dayAgoInMS } from 'utils/Utils'
import { activityToCommentItem, CommentType, DIFF2HTML_CONFIG, ViewStyle } from 'components/DiffViewer/DiffViewerUtils'
import { NavigationCheck } from 'components/NavigationCheck/NavigationCheck'
import { ThreadSection } from 'components/ThreadSection/ThreadSection'
import { PullRequestTabContentWrapper } from '../PullRequestTabContentWrapper'
import { DescriptionBox } from './DescriptionBox'
@ -175,6 +176,8 @@ export const Conversation: React.FC<ConversationProps> = ({
const { mutate: deleteComment } = useMutate({ verb: 'DELETE', path: ({ id }) => `${path}/${id}` })
const confirmAct = useConfirmAct()
const [commentCreated, setCommentCreated] = useState(false)
const [dirtyNewComment, setDirtyNewComment] = useState(false)
const [dirtyCurrentComments, setDirtyCurrentComments] = useState(false)
const refreshPR = useCallback(() => {
onCommentUpdate()
@ -316,6 +319,7 @@ export const Conversation: React.FC<ConversationProps> = ({
})}
commentItems={commentItems}
currentUserName={currentUser.display_name}
setDirty={setDirtyCurrentComments}
handleAction={async (action, value, commentItem) => {
let result = true
let updatedItem: CommentItem<TypesPullReqActivity> | undefined = undefined
@ -411,6 +415,7 @@ export const Conversation: React.FC<ConversationProps> = ({
currentUserName={currentUser.display_name}
resetOnSave
hideCancel
setDirty={setDirtyNewComment}
handleAction={async (_action, value) => {
let result = true
let updatedItem: CommentItem<TypesPullReqActivity> | undefined = undefined
@ -440,6 +445,7 @@ export const Conversation: React.FC<ConversationProps> = ({
</Container>
</Layout.Vertical>
</Container>
<NavigationCheck when={dirtyCurrentComments || dirtyNewComment} />
</PullRequestTabContentWrapper>
)
}

View File

@ -7,6 +7,7 @@ import { useStrings } from 'framework/strings'
import type { OpenapiUpdatePullReqRequest } from 'services/code'
import { OptionsMenuButton } from 'components/OptionsMenuButton/OptionsMenuButton'
import { MarkdownEditorWithPreview } from 'components/MarkdownEditorWithPreview/MarkdownEditorWithPreview'
import { NavigationCheck } from 'components/NavigationCheck/NavigationCheck'
import { getErrorMessage } from 'utils/Utils'
import type { ConversationProps } from './Conversation'
import css from './Conversation.module.scss'
@ -17,7 +18,7 @@ export const DescriptionBox: React.FC<ConversationProps> = ({
onCommentUpdate: refreshPullRequestMetadata
}) => {
const [edit, setEdit] = useState(false)
// const [updated, setUpdated] = useState(pullRequestMetadata.edited as number)
const [dirty, setDirty] = useState(false)
const [originalContent, setOriginalContent] = useState(pullRequestMetadata.description as string)
const [content, setContent] = useState(originalContent)
const { getString } = useStrings()
@ -52,6 +53,7 @@ export const DescriptionBox: React.FC<ConversationProps> = ({
setContent(originalContent)
setEdit(false)
}}
setDirty={setDirty}
i18n={{
placeHolder: getString('pr.enterDesc'),
tabEdit: getString('write'),
@ -84,6 +86,7 @@ export const DescriptionBox: React.FC<ConversationProps> = ({
</Container>
)}
</Container>
<NavigationCheck when={dirty} />
</Container>
)
}

View File

@ -24,6 +24,7 @@ import { filenameToLanguage, FILE_SEPERATOR } from 'utils/Utils'
import { useGetResourceContent } from 'hooks/useGetResourceContent'
import { CommitModalButton } from 'components/CommitModalButton/CommitModalButton'
import { DiffEditor } from 'components/SourceCodeEditor/MonacoSourceCodeEditor'
import { NavigationCheck } from 'components/NavigationCheck/NavigationCheck'
import css from './FileEditor.module.scss'
interface EditorProps extends Pick<GitInfoProps, 'repoMetadata' | 'gitRef' | 'resourcePath'> {
@ -97,6 +98,11 @@ function Editor({ resourceContent, repoMetadata, gitRef, resourcePath, isReposit
verifyFolder().then(() => setStartVerifyFolder(true))
}, [fileName, parentPath, language, content, verifyFolder])
const [selectedView, setSelectedView] = useState(VisualYamlSelectedView.VISUAL)
const [dirty, setDirty] = useState(false)
useEffect(() => {
setDirty(!(!fileName || (isUpdate && content === originalContent)))
}, [fileName, isUpdate, content, originalContent])
// Calculate file name input field width based on number of characters inside
useEffect(() => {
@ -194,7 +200,7 @@ function Editor({ resourceContent, repoMetadata, gitRef, resourcePath, isReposit
<CommitModalButton
text={getString('commitChanges')}
variation={ButtonVariation.PRIMARY}
disabled={!fileName || (isUpdate && content === originalContent)}
disabled={!dirty}
repoMetadata={repoMetadata}
commitAction={commitAction}
commitTitlePlaceHolder={getString(isNew ? 'createFile' : isUpdate ? 'updateFile' : 'renameFile')
@ -206,6 +212,8 @@ function Editor({ resourceContent, repoMetadata, gitRef, resourcePath, isReposit
payload={content}
sha={resourceContent?.sha}
onSuccess={(_data, newBranch) => {
setDirty(false)
if (newBranch) {
history.replace(
routes.toCODECompare({
@ -259,6 +267,7 @@ function Editor({ resourceContent, repoMetadata, gitRef, resourcePath, isReposit
<DiffEditor language={language} original={originalContent} source={content} onChange={setContent} />
)}
</Container>
<NavigationCheck when={dirty} />
</Container>
)
}