feat: Allow empty PR description

This commit is contained in:
“tan-nhu” 2023-06-02 17:21:32 -07:00
parent 674dcc11d2
commit 404631a22d
12 changed files with 249 additions and 154 deletions

View File

@ -27,7 +27,7 @@ import type { CODERoutes } from 'RouteDefinitions'
import css from './CommitsView.module.scss'
interface CommitsViewProps extends Pick<GitInfoProps, 'repoMetadata'> {
commits: TypesCommit[]
commits: TypesCommit[] | null
emptyTitle: string
emptyMessage: string
prHasChanged?: boolean
@ -116,7 +116,7 @@ export function CommitsView({
/>
)}
</Layout.Horizontal>
{!!commits.length &&
{!!commits?.length &&
Object.entries(commitsGroupedByDate).map(([date, commitsByDate]) => {
return (
<ThreadSection

View File

@ -8,7 +8,11 @@ export enum UserPreference {
PULL_REQUEST_CREATION_OPTION = 'PULL_REQUEST_CREATION_OPTION'
}
export function useUserPreference<T = string>(key: UserPreference, defaultValue: T): [T, (val: T) => void] {
export function useUserPreference<T = string>(
key: UserPreference,
defaultValue: T,
filter: (val: T) => boolean = () => true
): [T, (val: T) => void] {
const prefKey = `CODE_MOD_USER_PREF__${key}`
const convert = useCallback(
val => {
@ -40,14 +44,16 @@ export function useUserPreference<T = string>(key: UserPreference, defaultValue:
const savePreference = useCallback(
(val: T) => {
try {
if (filter(val)) {
localStorage[prefKey] = Array.isArray(val) || typeof val === 'object' ? JSON.stringify(val) : val
}
} catch (exception) {
// eslint-disable-next-line no-console
console.error('useUserPreference: Failed to stringify object', val)
}
setPreference(val)
},
[prefKey]
[prefKey, filter]
)
return [preference, savePreference]

View File

@ -85,16 +85,12 @@ export default function Compare() {
return showToaster(getString('pr.titleIsRequired'))
}
if (!description) {
return showToaster(getString('pr.descIsRequired'))
}
const pullReqUrl = window.location.href.split('compare')?.[0]
const payload: OpenapiCreatePullReqRequest = {
target_branch: targetGitRef,
source_branch: sourceGitRef,
title: title,
description: description,
description: description || '',
is_draft: creationType === PRCreationType.DRAFT
}
@ -243,7 +239,7 @@ export default function Compare() {
</Container>
<Container className={css.markdownContainer}>
<Layout.Vertical spacing="small">
<Text font={{ variation: FontVariation.SMALL_BOLD }}>{getString('description')} *</Text>
<Text font={{ variation: FontVariation.SMALL_BOLD }}>{getString('description')}</Text>
<MarkdownEditorWithPreview
value={description}
onChange={setDescription}

View File

@ -27,13 +27,17 @@ import css from './Conversation.module.scss'
export interface ConversationProps extends Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'> {
onCommentUpdate: () => void
prHasChanged?: boolean
showEditDescription?: boolean
onCancelEditDescription: () => void
}
export const Conversation: React.FC<ConversationProps> = ({
repoMetadata,
pullRequestMetadata,
onCommentUpdate,
prHasChanged
prHasChanged,
showEditDescription,
onCancelEditDescription
}) => {
const { getString } = useStrings()
const { currentUser } = useAppContext()
@ -116,6 +120,10 @@ export const Conversation: React.FC<ConversationProps> = ({
onCommentUpdate()
refetchActivities()
}, [onCommentUpdate, refetchActivities])
const hasDescription = useMemo(
() => !!pullRequestMetadata?.description?.length,
[pullRequestMetadata?.description?.length]
)
useEffect(() => {
if (prHasChanged) {
@ -136,12 +144,17 @@ export const Conversation: React.FC<ConversationProps> = ({
<Layout.Horizontal>
<Container width={`70%`}>
<Layout.Vertical spacing="xlarge">
{(hasDescription || showEditDescription) && (
<DescriptionBox
repoMetadata={repoMetadata}
pullRequestMetadata={pullRequestMetadata}
onCommentUpdate={onCommentUpdate}
onCancelEditDescription={onCancelEditDescription}
/>
<Layout.Horizontal className={css.sortContainer} padding={{ top: 'xxlarge', bottom: 'medium' }}>
)}
<Layout.Horizontal
className={css.sortContainer}
padding={{ top: hasDescription || showEditDescription ? 'xxlarge' : undefined, bottom: 'medium' }}>
<Container>
<Select
items={activityFilters}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import { Container, useToaster } from '@harness/uicore'
import cx from 'classnames'
import { useMutate } from 'restful-react'
@ -12,10 +12,15 @@ import { getErrorMessage } from 'utils/Utils'
import type { ConversationProps } from './Conversation'
import css from './Conversation.module.scss'
export const DescriptionBox: React.FC<ConversationProps> = ({
interface DescriptionBoxProps extends Omit<ConversationProps, 'onCancelEditDescription'> {
onCancelEditDescription: () => void
}
export const DescriptionBox: React.FC<DescriptionBoxProps> = ({
repoMetadata,
pullRequestMetadata,
onCommentUpdate: refreshPullRequestMetadata
onCommentUpdate: refreshPullRequestMetadata,
onCancelEditDescription
}) => {
const [edit, setEdit] = useState(false)
const [dirty, setDirty] = useState(false)
@ -28,6 +33,10 @@ export const DescriptionBox: React.FC<ConversationProps> = ({
path: `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata.number}`
})
useEffect(() => {
setEdit(!pullRequestMetadata?.description?.length)
}, [pullRequestMetadata?.description?.length])
return (
<Container className={cx({ [css.box]: !edit, [css.desc]: !edit })}>
<Container padding={!edit ? { left: 'small', bottom: 'small' } : undefined}>
@ -37,14 +46,13 @@ export const DescriptionBox: React.FC<ConversationProps> = ({
onSave={value => {
const payload: OpenapiUpdatePullReqRequest = {
title: pullRequestMetadata.title,
description: value
description: value || ''
}
mutate(payload)
.then(() => {
setContent(value)
setOriginalContent(value)
setEdit(false)
// setUpdated(Date.now())
refreshPullRequestMetadata()
})
.catch(exception => showError(getErrorMessage(exception), 0, getString('pr.failedToUpdate')))
@ -52,6 +60,7 @@ export const DescriptionBox: React.FC<ConversationProps> = ({
onCancel={() => {
setContent(originalContent)
setEdit(false)
onCancelEditDescription()
}}
setDirty={setDirty}
i18n={{
@ -62,6 +71,7 @@ export const DescriptionBox: React.FC<ConversationProps> = ({
cancel: getString('cancel')
}}
editorHeight="400px"
autoFocusAndPositioning
/>
)) || (
<Container className={css.mdWrapper}>

View File

@ -133,7 +133,8 @@ export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
const [mergeOption, setMergeOption] = useUserPreference<PRMergeOption>(
UserPreference.PULL_REQUEST_MERGE_STRATEGY,
mergeOptions[1]
mergeOptions[1],
option => option.method !== 'close'
)
const [draftOption, setDraftOption] = useState<PRDraftOption>(draftOptions[0])
const permPushResult = hooks?.usePermissionTranslate?.(

View File

@ -1,26 +1,14 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import {
Container,
PageBody,
Text,
FontVariation,
Tabs,
Layout,
Button,
ButtonVariation,
ButtonSize,
TextInput,
useToaster
} from '@harness/uicore'
import { useGet, useMutate } from 'restful-react'
import { Render, Match, Truthy, Else } from 'react-jsx-match'
import { Container, PageBody, Tabs } from '@harness/uicore'
import { useGet } from 'restful-react'
import { Render } from 'react-jsx-match'
import { useHistory } from 'react-router-dom'
import { useAppContext } from 'AppContext'
import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
import { useStrings } from 'framework/strings'
import { RepositoryPageHeader } from 'components/RepositoryPageHeader/RepositoryPageHeader'
import { voidFn, getErrorMessage } from 'utils/Utils'
import { CodeIcon, GitInfoProps } from 'utils/GitUtils'
import { CodeIcon } from 'utils/GitUtils'
import type { TypesPullReq, TypesPullReqStats, TypesRepository } from 'services/code'
import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
import { TabTitleWithCount, tabContainerCSS } from 'components/TabTitleWithCount/TabTitleWithCount'
@ -29,6 +17,7 @@ import { Conversation } from './Conversation/Conversation'
import { Checks } from './Checks/Checks'
import { Changes } from '../../components/Changes/Changes'
import { PullRequestCommits } from './PullRequestCommits/PullRequestCommits'
import { PullRequestTitle } from './PullRequestTitle'
import css from './PullRequest.module.scss'
export default function PullRequest() {
@ -61,6 +50,7 @@ export default function PullRequest() {
return loading || (prLoading && !prData)
}, [loading, prLoading, prData])
const [stats, setStats] = useState<TypesPullReqStats>()
const [showEditDescription, setShowEditDescription] = useState(false)
const prHasChanged = useMemo(() => {
if (stats && prData?.stats) {
if (
@ -74,6 +64,9 @@ export default function PullRequest() {
}
return false
}, [prData?.stats, stats])
const onAddDescriptionClick = useCallback(() => {
setShowEditDescription(true)
}, [])
useEffect(
function setStatsIfNotSet() {
@ -120,7 +113,13 @@ export default function PullRequest() {
<Container className={css.main}>
<RepositoryPageHeader
repoMetadata={repoMetadata}
title={repoMetadata && prData ? <PullRequestTitle repoMetadata={repoMetadata} {...prData} /> : ''}
title={
repoMetadata && prData ? (
<PullRequestTitle repoMetadata={repoMetadata} {...prData} onAddDescriptionClick={onAddDescriptionClick} />
) : (
''
)
}
dataTooltipId="repositoryPullRequests"
extraBreadcrumbLinks={
repoMetadata && [
@ -165,8 +164,13 @@ export default function PullRequest() {
<Conversation
repoMetadata={repoMetadata as TypesRepository}
pullRequestMetadata={prData as TypesPullReq}
onCommentUpdate={voidFn(refetchPullRequest)}
onCommentUpdate={() => {
setShowEditDescription(false)
refetchPullRequest()
}}
prHasChanged={prHasChanged}
showEditDescription={showEditDescription}
onCancelEditDescription={() => setShowEditDescription(false)}
/>
)
},
@ -237,92 +241,6 @@ export default function PullRequest() {
)
}
interface PullRequestTitleProps extends TypesPullReq, Pick<GitInfoProps, 'repoMetadata'> {
onSaveDone?: (newTitle: string) => Promise<boolean>
}
const PullRequestTitle: React.FC<PullRequestTitleProps> = ({ repoMetadata, title, number, description }) => {
const [original, setOriginal] = useState(title)
const [val, setVal] = useState(title)
const [edit, setEdit] = useState(false)
const { getString } = useStrings()
const { showError } = useToaster()
const { mutate } = useMutate({
verb: 'PATCH',
path: `/api/v1/repos/${repoMetadata.path}/+/pullreq/${number}`
})
const submitChange = useCallback(() => {
mutate({
title: val,
description
})
.then(() => {
setEdit(false)
setOriginal(val)
})
.catch(exception => showError(getErrorMessage(exception), 0))
}, [description, val, mutate, showError])
return (
<Layout.Horizontal spacing="xsmall" className={css.prTitle}>
<Match expr={edit}>
<Truthy>
<Container>
<Layout.Horizontal spacing="small">
<TextInput
wrapperClassName={css.input}
value={val}
onFocus={event => event.target.select()}
onInput={event => setVal(event.currentTarget.value)}
autoFocus
onKeyDown={event => {
switch (event.key) {
case 'Enter':
submitChange()
break
case 'Escape': // does not work, maybe TextInput cancels ESC?
setEdit(false)
break
}
}}
/>
<Button
variation={ButtonVariation.PRIMARY}
text={getString('save')}
size={ButtonSize.MEDIUM}
disabled={(val || '').trim().length === 0 || title === val}
onClick={submitChange}
/>
<Button
variation={ButtonVariation.TERTIARY}
text={getString('cancel')}
size={ButtonSize.MEDIUM}
onClick={() => setEdit(false)}
/>
</Layout.Horizontal>
</Container>
</Truthy>
<Else>
<>
<Text tag="h1" font={{ variation: FontVariation.H4 }}>
{original} <span className={css.prNumber}>#{number}</span>
</Text>
<Button
variation={ButtonVariation.ICON}
tooltip={getString('edit')}
tooltipProps={{ isDark: true, position: 'right' }}
size={ButtonSize.SMALL}
icon="code-edit"
className={css.btn}
onClick={() => setEdit(true)}
/>
</>
</Else>
</Match>
</Layout.Horizontal>
)
}
enum PullRequestSection {
CONVERSATION = 'conversation',
COMMITS = 'commits',

View File

@ -43,7 +43,7 @@ export const PullRequestCommits: React.FC<CommitProps> = ({
return (
<PullRequestTabContentWrapper loading={loading} error={error} onRetry={voidFn(refetch)}>
<CommitsView
commits={commits || []}
commits={commits}
repoMetadata={repoMetadata}
emptyTitle={getString('noCommits')}
emptyMessage={getString('noCommitsPR')}

View File

@ -0,0 +1,121 @@
import React, { useCallback, useState } from 'react'
import {
Container,
Text,
FontVariation,
Layout,
Button,
ButtonVariation,
ButtonSize,
TextInput,
useToaster
} from '@harness/uicore'
import { useMutate } from 'restful-react'
import { Match, Truthy, Else } from 'react-jsx-match'
import { useStrings } from 'framework/strings'
import { ButtonRoleProps, getErrorMessage } from 'utils/Utils'
import type { GitInfoProps } from 'utils/GitUtils'
import type { TypesPullReq } from 'services/code'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import css from './PullRequest.module.scss'
interface PullRequestTitleProps extends TypesPullReq, Pick<GitInfoProps, 'repoMetadata'> {
onSaveDone?: (newTitle: string) => Promise<boolean>
onAddDescriptionClick: () => void
}
export const PullRequestTitle: React.FC<PullRequestTitleProps> = ({
repoMetadata,
title,
number,
description,
onAddDescriptionClick
}) => {
const [original, setOriginal] = useState(title)
const [val, setVal] = useState(title)
const [edit, setEdit] = useState(false)
const { getString } = useStrings()
const { showError } = useToaster()
const { mutate } = useMutate({
verb: 'PATCH',
path: `/api/v1/repos/${repoMetadata.path}/+/pullreq/${number}`
})
const submitChange = useCallback(() => {
mutate({
title: val,
description
})
.then(() => {
setEdit(false)
setOriginal(val)
})
.catch(exception => showError(getErrorMessage(exception), 0))
}, [description, val, mutate, showError])
return (
<Layout.Horizontal spacing="small" className={css.prTitle}>
<Match expr={edit}>
<Truthy>
<Container>
<Layout.Horizontal spacing="small">
<TextInput
wrapperClassName={css.input}
value={val}
onFocus={event => event.target.select()}
onInput={event => setVal(event.currentTarget.value)}
autoFocus
onKeyDown={event => {
switch (event.key) {
case 'Enter':
submitChange()
break
case 'Escape': // does not work, maybe TextInput cancels ESC?
setEdit(false)
break
}
}}
/>
<Button
variation={ButtonVariation.PRIMARY}
text={getString('save')}
size={ButtonSize.MEDIUM}
disabled={(val || '').trim().length === 0 || title === val}
onClick={submitChange}
/>
<Button
variation={ButtonVariation.TERTIARY}
text={getString('cancel')}
size={ButtonSize.MEDIUM}
onClick={() => setEdit(false)}
/>
</Layout.Horizontal>
</Container>
</Truthy>
<Else>
<>
<Text tag="h1" font={{ variation: FontVariation.H4 }}>
{original} <span className={css.prNumber}>#{number}</span>
</Text>
<Button
variation={ButtonVariation.ICON}
tooltip={getString('edit')}
tooltipProps={{ isDark: true, position: 'right' }}
size={ButtonSize.SMALL}
icon="code-edit"
className={css.btn}
onClick={() => setEdit(true)}
/>
{!(description || '').trim().length && (
<>
<PipeSeparator height={10} />
<a {...ButtonRoleProps} onClick={onAddDescriptionClick}>
&nbsp;Add Description
</a>
</>
)}
</>
</Else>
</Match>
</Layout.Horizontal>
)
}

View File

@ -11,6 +11,7 @@
.title {
font-weight: 600;
display: flex;
.convoIcon {
padding-top: 1px !important;
}
@ -20,7 +21,7 @@
.titleRow {
padding-left: var(--spacing-small);
align-items: start;
align-items: center;
}
}

View File

@ -9,7 +9,8 @@ import {
StringSubstitute,
Icon,
FontVariation,
FlexExpander
FlexExpander,
Utils
} from '@harness/uicore'
import { useHistory } from 'react-router-dom'
import { useGet } from 'restful-react'
@ -30,6 +31,8 @@ import type { TypesPullReq, TypesRepository } from 'services/code'
import { ResourceListingPagination } from 'components/ResourceListingPagination/ResourceListingPagination'
import { UserPreference, useUserPreference } from 'hooks/useUserPreference'
import { NoResultCard } from 'components/NoResultCard/NoResultCard'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import { GitRefLink } from 'components/GitRefLink/GitRefLink'
import { PullRequestStateLabel } from 'components/PullRequestStateLabel/PullRequestStateLabel'
import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
import { ExecutionStatusLabel } from 'components/ExecutionStatusLabel/ExecutionStatusLabel'
@ -97,7 +100,7 @@ export default function PullRequests() {
<Layout.Horizontal className={css.titleRow} spacing="medium">
<PullRequestStateLabel iconSize={22} data={row.original} iconOnly />
<Container padding={{ left: 'small' }}>
<Layout.Vertical spacing="small">
<Layout.Vertical spacing="xsmall">
<Text color={Color.GREY_800} className={css.title}>
{row.original.title}
<Icon
@ -110,6 +113,8 @@ export default function PullRequests() {
{row.original.stats?.conversations}
</Text>
</Text>
<Container>
<Layout.Horizontal spacing="small" style={{ alignItems: 'center' }}>
<Text color={Color.GREY_500} font={{ size: 'small' }}>
<StringSubstitute
str={getString('pr.statusLine')}
@ -120,7 +125,9 @@ export default function PullRequests() {
<strong>
<ReactTimeago
date={
(row.original.state == 'merged' ? row.original.merged : row.original.created) as number
(row.original.state == 'merged'
? row.original.merged
: row.original.created) as number
}
/>
</strong>
@ -129,10 +136,32 @@ export default function PullRequests() {
}}
/>
</Text>
<PipeSeparator height={10} />
<Container>
<Layout.Horizontal spacing="xsmall" style={{ alignItems: 'center' }} onClick={Utils.stopEvent}>
<GitRefLink
text={row.original.target_branch as string}
url={routes.toCODERepository({
repoPath: repoMetadata?.path as string,
gitRef: row.original.target_branch
})}
/>
<Text color={Color.GREY_500}></Text>
<GitRefLink
text={row.original.source_branch as string}
url={routes.toCODERepository({
repoPath: repoMetadata?.path as string,
gitRef: row.original.source_branch
})}
/>
</Layout.Horizontal>
</Container>
</Layout.Horizontal>
</Container>
</Layout.Vertical>
</Container>
<FlexExpander />
{/* fix state when api is fully implemented */}
{/* TODO: Pass proper state when check api is fully implemented */}
<ExecutionStatusLabel data={{ state: 'success' }} />
</Layout.Horizontal>
)

View File

@ -115,7 +115,7 @@ export function formatNumber(num: number | bigint): string {
* support (hit Enter/Space will trigger click event)
*/
export const ButtonRoleProps = {
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar' || e.which === 13 || e.which === 32) {
;(e.target as unknown as { click: () => void })?.click?.()
}