drone/web/src/pages/PullRequest/PullRequest.tsx
2023-05-24 17:54:28 -07:00

360 lines
12 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react'
import {
Container,
PageBody,
Text,
FontVariation,
Tabs,
IconName,
HarnessIcons,
Layout,
Button,
ButtonVariation,
ButtonSize,
TextInput,
useToaster,
Spacing,
PaddingProps
} from '@harness/uicore'
import { useGet, useMutate } from 'restful-react'
import { Render, Match, Truthy, Else } 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 type { TypesPullReq, TypesPullReqStats, TypesRepository } from 'services/code'
import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
import { PullRequestMetaLine } from './PullRequestMetaLine'
import { Conversation } from './Conversation/Conversation'
import { Checks } from './Checks/Checks'
import { Changes } from '../../components/Changes/Changes'
import { PullRequestCommits } from './PullRequestCommits/PullRequestCommits'
import css from './PullRequest.module.scss'
export default function PullRequest() {
const history = useHistory()
const { getString } = useStrings()
const { routes } = useAppContext()
const {
repoMetadata,
error,
loading,
refetch,
pullRequestId,
pullRequestSection = PullRequestSection.CONVERSATION
} = useGetRepositoryMetadata()
const path = useMemo(
() => `/api/v1/repos/${repoMetadata?.path}/+/pullreq/${pullRequestId}`,
[repoMetadata?.path, pullRequestId]
)
const {
data: pullRequestData,
error: prError,
loading: prLoading,
refetch: refetchPullRequest
} = useGet<TypesPullReq>({
path,
lazy: !repoMetadata
})
const [prData, setPrData] = useState<TypesPullReq>()
const showSpinner = useMemo(() => {
return loading || (prLoading && !prData)
}, [loading, prLoading, prData])
const [stats, setStats] = useState<TypesPullReqStats>()
const prHasChanged = useMemo(() => {
if (stats && prData?.stats) {
if (
stats.commits !== prData.stats.commits ||
stats.conversations !== prData.stats.conversations ||
stats.files_changed !== prData.stats.files_changed
) {
window.setTimeout(() => setStats(prData.stats), 50)
return true
}
}
return false
}, [prData?.stats, stats])
useEffect(
function setStatsIfNotSet() {
if (!stats && prData?.stats) {
setStats(prData.stats)
}
},
[prData?.stats, stats]
)
// prData holds the latest good PR data to make sure page is not broken
// when polling fails
useEffect(
function setPrDataIfNotSet() {
if (pullRequestData) {
setPrData(pullRequestData)
}
},
[pullRequestData]
)
useEffect(() => {
const fn = () => {
if (repoMetadata) {
refetchPullRequest().then(() => {
interval = window.setTimeout(fn, PR_POLLING_INTERVAL)
})
}
}
let interval = window.setTimeout(fn, PR_POLLING_INTERVAL)
return () => window.clearTimeout(interval)
}, [repoMetadata, refetchPullRequest, path])
const activeTab = useMemo(
() =>
Object.values(PullRequestSection).find(value => value === pullRequestSection)
? pullRequestSection
: PullRequestSection.CONVERSATION,
[pullRequestSection]
)
return (
<Container className={css.main}>
<RepositoryPageHeader
repoMetadata={repoMetadata}
title={repoMetadata && prData ? <PullRequestTitle repoMetadata={repoMetadata} {...prData} /> : ''}
dataTooltipId="repositoryPullRequests"
extraBreadcrumbLinks={
repoMetadata && [
{
label: getString('pullRequests'),
url: routes.toCODEPullRequests({ repoPath: repoMetadata.path as string })
}
]
}
/>
<PageBody error={!prData && getErrorMessage(error || prError)} retryOnError={voidFn(refetch)}>
<LoadingSpinner visible={showSpinner} />
<Render when={repoMetadata && prData}>
<>
<PullRequestMetaLine repoMetadata={repoMetadata as TypesRepository} {...prData} />
<Container className={css.tabsContainer}>
<Tabs
id="prTabs"
defaultSelectedTabId={activeTab}
large={false}
onChange={tabId => {
history.replace(
routes.toCODEPullRequest({
repoPath: repoMetadata?.path as string,
pullRequestId,
pullRequestSection: tabId !== PullRequestSection.CONVERSATION ? (tabId as string) : undefined
})
)
}}
tabList={[
{
id: PullRequestSection.CONVERSATION,
title: (
<TabTitle
icon={CodeIcon.Chat}
title={getString('conversation')}
count={prData?.stats?.conversations || 0}
/>
),
panel: (
<Conversation
repoMetadata={repoMetadata as TypesRepository}
pullRequestMetadata={prData as TypesPullReq}
onCommentUpdate={voidFn(refetchPullRequest)}
prHasChanged={prHasChanged}
/>
)
},
{
id: PullRequestSection.COMMITS,
title: (
<TabTitle
icon={CodeIcon.Commit}
title={getString('commits')}
count={prData?.stats?.commits || 0}
padding={{ left: 'medium' }}
/>
),
panel: (
<PullRequestCommits
repoMetadata={repoMetadata as TypesRepository}
pullRequestMetadata={prData as TypesPullReq}
prHasChanged={prHasChanged}
handleRefresh={voidFn(refetchPullRequest)}
/>
)
},
{
id: PullRequestSection.FILES_CHANGED,
title: (
<TabTitle
icon={CodeIcon.File}
title={getString('filesChanged')}
count={prData?.stats?.files_changed || 0}
padding={{ left: 'medium' }}
/>
),
panel: (
<Container className={css.changes}>
<Changes
repoMetadata={repoMetadata as TypesRepository}
pullRequestMetadata={prData as TypesPullReq}
targetBranch={prData?.target_branch}
sourceBranch={prData?.source_branch}
emptyTitle={getString('noChanges')}
emptyMessage={getString('noChangesPR')}
onCommentUpdate={voidFn(refetchPullRequest)}
prHasChanged={prHasChanged}
/>
</Container>
)
},
{
id: PullRequestSection.CHECKS,
disabled: window.location.hostname !== 'localhost', // TODO: Remove when API supports checks
title: (
<TabTitle
icon={CodeIcon.ChecksSuccess}
title={getString('checks')}
count={0} // TODO: Count for checks when API supports it
padding={{ left: 'medium' }}
/>
),
panel: <Checks />
}
]}
/>
</Container>
</>
</Render>
</PageBody>
</Container>
)
}
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>
)
}
const TabTitle: React.FC<{ icon: IconName; title: string; count?: number; padding?: Spacing | PaddingProps }> = ({
icon,
title,
count,
padding
}) => {
// Icon inside a tab got overriden-and-looked-bad styles from UICore
// on hover. Use icon directly instead
const TabIcon: React.ElementType = HarnessIcons[icon]
return (
<Text className={css.tabTitle} padding={padding}>
<TabIcon width={16} height={16} />
{title}
<Render when={count}>
<Text inline className={css.count}>
{count}
</Text>
</Render>
</Text>
)
}
enum PullRequestSection {
CONVERSATION = 'conversation',
COMMITS = 'commits',
FILES_CHANGED = 'changes',
CHECKS = 'checks'
}
const PR_POLLING_INTERVAL = 10000