Add PR listing page + Commits tab for PR detail page (#123)

* Add PR listing page + Commits tab for PR detail page

* Add commits place-holder in Compare view

* Add commits place-holder in Compare view

* Correct PR number after creation

* Minor CSS improvement

* Add diff sample

* Diff side by side (3.4.2 is better than latest)

* Big PR diff example

* Implement diff view

* Scrolling optimization

* Add placeholder to allow click at line number
This commit is contained in:
Tan Nhu 2022-12-12 16:39:14 -08:00 committed by GitHub
parent fe9118e074
commit 619fd2c9de
50 changed files with 32936 additions and 257 deletions

View File

@ -56,6 +56,7 @@
"copy-to-clipboard": "^3.3.1", "copy-to-clipboard": "^3.3.1",
"cron-validator": "^1.2.1", "cron-validator": "^1.2.1",
"cronstrue": "^1.114.0", "cronstrue": "^1.114.0",
"diff2html": "3.4.22",
"event-source-polyfill": "^1.0.22", "event-source-polyfill": "^1.0.22",
"formik": "2.2.9", "formik": "2.2.9",
"github-markdown-css": "^5.1.0", "github-markdown-css": "^5.1.0",
@ -83,6 +84,7 @@
"react-contenteditable": "^3.3.5", "react-contenteditable": "^3.3.5",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-draggable": "^4.4.2", "react-draggable": "^4.4.2",
"react-intersection-observer": "^9.4.1",
"react-join": "^1.1.4", "react-join": "^1.1.4",
"react-keywords": "^0.0.5", "react-keywords": "^0.0.5",
"react-lottie-player": "^1.4.0", "react-lottie-player": "^1.4.0",

View File

@ -8,6 +8,7 @@ export interface CODEProps {
branch?: string branch?: string
diffRefs?: string diffRefs?: string
pullRequestId?: string pullRequestId?: string
pullRequestSection?: string
} }
export interface CODEQueryProps { export interface CODEQueryProps {
@ -21,7 +22,8 @@ export const pathProps: Readonly<Omit<Required<CODEProps>, 'repoPath' | 'branch'
resourcePath: ':resourcePath*', resourcePath: ':resourcePath*',
commitRef: ':commitRef*', commitRef: ':commitRef*',
diffRefs: ':diffRefs*', diffRefs: ':diffRefs*',
pullRequestId: ':pullRequestId' pullRequestId: ':pullRequestId',
pullRequestSection: ':pullRequestSection*'
} }
export interface CODERoutes { export interface CODERoutes {
@ -32,7 +34,12 @@ export interface CODERoutes {
toCODEFileEdit: (args: Required<Pick<CODEProps, 'repoPath' | 'gitRef' | 'resourcePath'>>) => string toCODEFileEdit: (args: Required<Pick<CODEProps, 'repoPath' | 'gitRef' | 'resourcePath'>>) => string
toCODECommits: (args: Required<Pick<CODEProps, 'repoPath' | 'commitRef'>>) => string toCODECommits: (args: Required<Pick<CODEProps, 'repoPath' | 'commitRef'>>) => string
toCODEPullRequests: (args: Required<Pick<CODEProps, 'repoPath'>>) => string toCODEPullRequests: (args: Required<Pick<CODEProps, 'repoPath'>>) => string
toCODEPullRequest: (args: Required<Pick<CODEProps, 'repoPath' | 'pullRequestId'>>) => string toCODEPullRequest: (
args: RequiredField<
Pick<CODEProps, 'repoPath' | 'pullRequestId' | 'pullRequestSection'>,
'repoPath' | 'pullRequestId'
>
) => string
toCODECompare: (args: Required<Pick<CODEProps, 'repoPath' | 'diffRefs'>>) => string toCODECompare: (args: Required<Pick<CODEProps, 'repoPath' | 'diffRefs'>>) => string
toCODEBranches: (args: Required<Pick<CODEProps, 'repoPath'>>) => string toCODEBranches: (args: Required<Pick<CODEProps, 'repoPath'>>) => string
toCODESettings: (args: Required<Pick<CODEProps, 'repoPath'>>) => string toCODESettings: (args: Required<Pick<CODEProps, 'repoPath'>>) => string
@ -48,7 +55,8 @@ export const routes: CODERoutes = {
toCODEFileEdit: ({ repoPath, gitRef, resourcePath }) => `/${repoPath}/edit/${gitRef}/~/${resourcePath}`, toCODEFileEdit: ({ repoPath, gitRef, resourcePath }) => `/${repoPath}/edit/${gitRef}/~/${resourcePath}`,
toCODECommits: ({ repoPath, commitRef }) => `/${repoPath}/commits/${commitRef}`, toCODECommits: ({ repoPath, commitRef }) => `/${repoPath}/commits/${commitRef}`,
toCODEPullRequests: ({ repoPath }) => `/${repoPath}/pulls`, toCODEPullRequests: ({ repoPath }) => `/${repoPath}/pulls`,
toCODEPullRequest: ({ repoPath, pullRequestId }) => `/${repoPath}/pulls/${pullRequestId}`, toCODEPullRequest: ({ repoPath, pullRequestId, pullRequestSection }) =>
`/${repoPath}/pulls/${pullRequestId}${pullRequestSection ? '/' + pullRequestSection : ''}`,
toCODECompare: ({ repoPath, diffRefs }) => `/${repoPath}/pulls/compare/${diffRefs}`, toCODECompare: ({ repoPath, diffRefs }) => `/${repoPath}/pulls/compare/${diffRefs}`,
toCODEBranches: ({ repoPath }) => `/${repoPath}/branches`, toCODEBranches: ({ repoPath }) => `/${repoPath}/branches`,
toCODESettings: ({ repoPath }) => `/${repoPath}/settings`, toCODESettings: ({ repoPath }) => `/${repoPath}/settings`,

View File

@ -1,7 +1,6 @@
import React, { CSSProperties } from 'react' import React, { CSSProperties } from 'react'
import { Container, Popover, Text } from '@harness/uicore' import { Container, Popover, StringSubstitute, Text } from '@harness/uicore'
import { useStrings } from 'framework/strings' import { useStrings } from 'framework/strings'
import { StringSubstitute } from 'components/StringSubstitute/StringSubstitute'
import css from './CommitDivergence.module.scss' import css from './CommitDivergence.module.scss'
interface CommitDivergenceProps { interface CommitDivergenceProps {

View File

@ -8,13 +8,13 @@ import type { RepoCommit } from 'services/code'
import { CommitActions } from 'components/CommitActions/CommitActions' import { CommitActions } from 'components/CommitActions/CommitActions'
import { formatDate } from 'utils/Utils' import { formatDate } from 'utils/Utils'
import { CodeIcon, GitInfoProps } from 'utils/GitUtils' import { CodeIcon, GitInfoProps } from 'utils/GitUtils'
import css from './CommitsContent.module.scss' import css from './CommitsView.module.scss'
interface CommitsContentProps extends Pick<GitInfoProps, 'repoMetadata'> { interface CommitsViewProps extends Pick<GitInfoProps, 'repoMetadata'> {
commits: RepoCommit[] commits: RepoCommit[]
} }
export function CommitsContent({ repoMetadata, commits }: CommitsContentProps) { export function CommitsView({ repoMetadata, commits }: CommitsViewProps) {
const { getString } = useStrings() const { getString } = useStrings()
const { routes } = useAppContext() const { routes } = useAppContext()
const columns: Column<RepoCommit>[] = useMemo( const columns: Column<RepoCommit>[] = useMemo(

View File

@ -0,0 +1,8 @@
.spinner {
width: 100%;
height: 100%;
> div {
position: relative !important;
}
}

View File

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

View File

@ -0,0 +1,12 @@
import React from 'react'
import cx from 'classnames'
import { Container, PageSpinner } from '@harness/uicore'
import css from './ContainerSpinner.module.scss'
export const ContainerSpinner: React.FC<React.ComponentProps<typeof Container>> = ({ className, ...props }) => {
return (
<Container className={cx(css.spinner, className)} {...props}>
<PageSpinner />
</Container>
)
}

View File

@ -0,0 +1,106 @@
.main {
--border-color: var(--grey-200);
border-radius: 5px;
min-height: 36px;
:global {
.d2h-wrapper > div {
margin-bottom: 0;
}
.d2h-file-wrapper {
border: 0;
}
.d2h-file-header {
display: none;
}
.d2h-files-diff {
.d2h-code-side-linenumber {
border-left: 0;
}
}
.d2h-file-side-diff {
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
}
.d2h-code-side-linenumber {
width: 56px;
}
.d2h-diff-tbody {
position: relative;
}
}
&.collapsed {
.diffHeader {
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
}
.diffContent {
display: none;
}
}
&.offscreen {
.diffContent {
display: none;
}
}
.diffHeader {
display: grid;
align-items: center;
background-color: var(--grey-100);
position: sticky;
top: var(--diff-viewer-sticky-top, 0);
z-index: 1;
padding: 5px 10px 5px 5px;
border: 1px solid var(--border-color);
border-top-left-radius: 5px;
border-top-right-radius: 5px;
.fname {
font-size: 13px !important;
font-weight: 600 !important;
color: var(--grey-900) !important;
align-self: center;
}
}
.viewLabel {
display: flex;
font-size: 12px;
color: var(--grey-600);
border: 1px solid var(--grey-200);
padding: 3px 6px;
border-radius: 3px;
cursor: pointer;
input {
display: inline-block;
margin-right: var(--spacing-small);
}
}
.diffContent {
border-left: 1px solid var(--border-color);
border-right: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
max-width: calc(100vw - 320px);
// &[data-display='none'] {
// visibility: hidden;
// }
}
}

View File

@ -0,0 +1,12 @@
/* eslint-disable */
// this is an auto-generated file
declare const styles: {
readonly main: string
readonly collapsed: string
readonly diffHeader: string
readonly diffContent: string
readonly offscreen: string
readonly fname: string
readonly viewLabel: string
}
export default styles

View File

@ -0,0 +1,285 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useInView } from 'react-intersection-observer'
import { Button, Color, Container, FlexExpander, ButtonVariation, Layout, Text, ButtonSize } from '@harness/uicore'
import type * as Diff2Html from 'diff2html'
import HoganJsUtils from 'diff2html/lib/hoganjs-utils'
import { Diff2HtmlUI } from 'diff2html/lib-esm/ui/js/diff2html-ui'
import 'highlight.js/styles/github.css'
import 'diff2html/bundles/css/diff2html.min.css'
import type { DiffFile } from 'diff2html/lib/types'
import { useStrings } from 'framework/strings'
import { CodeIcon } from 'utils/GitUtils'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import css from './DiffViewer.module.scss'
export enum DiffViewStyle {
SPLIT = 'side-by-side',
UNIFIED = 'line-by-line'
}
const DIFF_HEADER_HEIGHT = 36
const LINE_NUMBER_CLASS = 'diff-viewer-line-number'
export const DIFF2HTML_CONFIG = {
outputFormat: 'side-by-side',
drawFileList: false,
fileListStartVisible: false,
fileContentToggle: true,
matching: 'lines',
synchronisedScroll: true,
highlight: true,
renderNothingWhenEmpty: false,
compiledTemplates: {
'generic-line': HoganJsUtils.compile(`
<tr data-line="{{lineNumber}}">
<td class="{{lineClass}} {{type}} ${LINE_NUMBER_CLASS}">
{{{lineNumber}}}
</td>
<td class="{{type}}">
<div class="{{contentClass}}">
{{#prefix}}
<span class="d2h-code-line-prefix">{{{prefix}}}</span>
{{/prefix}}
{{^prefix}}
<span class="d2h-code-line-prefix">&nbsp;</span>
{{/prefix}}
{{#content}}
<span class="d2h-code-line-ctn">{{{content}}}</span>
{{/content}}
{{^content}}
<span class="d2h-code-line-ctn"><br></span>
{{/content}}
</div>
</td>
</tr>
`),
'line-by-line-numbers': HoganJsUtils.compile(`
<div class="line-num1 ${LINE_NUMBER_CLASS}">{{oldNumber}}</div>
<div class="line-num2 ${LINE_NUMBER_CLASS}">{{newNumber}}</div>
`)
}
} as Readonly<Diff2Html.Diff2HtmlConfig>
interface DiffViewerProps {
index: number
diff: DiffFile
viewStyle: DiffViewStyle
stickyTopPosition?: number
}
//
// Note: Lots of direct DOM manipulations are used to boost performance
// Avoid React re-rendering at all cost as it might cause unresponsive UI
// when diff content is big, or when a PR has a lot of changed files.
//
export const DiffViewer: React.FC<DiffViewerProps> = ({ index, diff, viewStyle, stickyTopPosition = 0 }) => {
const { getString } = useStrings()
const [viewed, setViewed] = useState(false)
const [collapsed, setCollapsed] = useState(false)
const [fileUnchanged] = useState(diff.unchangedPercentage === 100)
const [fileDeleted] = useState(diff.isDeleted)
const [renderCustomContent, setRenderCustomContent] = useState(fileUnchanged || fileDeleted)
const containerId = useMemo(() => `file-diff-container-${index}`, [index])
const contentId = useMemo(() => `${containerId}-content`, [containerId])
const [height, setHeight] = useState<number | string>('auto')
const [diffRenderer, setDiffRenderer] = useState<Diff2HtmlUI>()
const { ref: inViewRef, inView } = useInView({ rootMargin: '100px 0px' })
const containerRef = useRef<HTMLDivElement | null>(null)
const setContainerRef = useCallback(
node => {
containerRef.current = node
inViewRef(node)
},
[inViewRef]
)
const contentRef = useRef<HTMLDivElement>(null)
const setupViewerInitialStates = useCallback(() => {
setDiffRenderer(
new Diff2HtmlUI(
document.getElementById(contentId) as HTMLElement,
[diff],
Object.assign({}, DIFF2HTML_CONFIG, {
outputFormat: viewStyle
})
)
)
}, [diff, contentId, viewStyle])
const renderDiffAndUpdateContainerHeightIfNeeded = useCallback(
(enforced = false) => {
const contentDOM = contentRef.current as HTMLDivElement
const containerDOM = containerRef.current as HTMLDivElement
if (!contentDOM.dataset.rendered || enforced) {
if (!renderCustomContent || enforced) {
containerDOM.style.height = 'auto'
diffRenderer?.draw()
}
contentDOM.dataset.rendered = 'true'
setHeight(containerDOM.clientHeight)
}
},
[diffRenderer, renderCustomContent]
)
useEffect(
function createDiffRenderer() {
if (inView && !diffRenderer) {
setupViewerInitialStates()
}
},
[inView, diffRenderer, setupViewerInitialStates]
)
useEffect(
function renderInitialContent() {
if (diffRenderer) {
const container = containerRef.current as HTMLDivElement
const { classList: containerClassList } = container
if (inView) {
containerClassList.remove(css.offscreen)
renderDiffAndUpdateContainerHeightIfNeeded()
} else {
containerClassList.add(css.offscreen)
}
}
},
[inView, diffRenderer, renderDiffAndUpdateContainerHeightIfNeeded]
)
useEffect(
function handleCollapsedState() {
const containerDOM = containerRef.current as HTMLDivElement & { scrollIntoViewIfNeeded: () => void }
const { classList: containerClassList, style: containerStyle } = containerDOM
if (collapsed) {
containerClassList.add(css.collapsed)
// Fix scrolling position messes up with sticky header
const { y } = containerDOM.getBoundingClientRect()
if (y - stickyTopPosition < 1) {
containerDOM.scrollIntoView()
if (stickyTopPosition) {
window.scroll({ top: window.scrollY - stickyTopPosition })
}
}
if (parseInt(containerStyle.height) != DIFF_HEADER_HEIGHT) {
containerStyle.height = `${DIFF_HEADER_HEIGHT}px`
}
} else {
containerClassList.remove(css.collapsed)
if (parseInt(containerStyle.height) != height) {
containerStyle.height = `${height}px`
}
}
},
[collapsed, height, stickyTopPosition]
)
useEffect(function () {
const onClick = (event: MouseEvent) => {
const target = event.target as HTMLDivElement
if (target.classList.contains(LINE_NUMBER_CLASS)) {
console.log('line number clicked')
}
console.log({ event, target })
}
const containerDOM = containerRef.current as HTMLDivElement
containerDOM.addEventListener('click', onClick)
return () => {
containerDOM.removeEventListener('click', onClick)
}
}, [])
return (
<Container
ref={setContainerRef}
id={containerId}
className={css.main}
style={{ '--diff-viewer-sticky-top': `${stickyTopPosition}px` } as React.CSSProperties}>
<Layout.Vertical>
<Container className={css.diffHeader} height={DIFF_HEADER_HEIGHT}>
<Layout.Horizontal>
<Button
variation={ButtonVariation.ICON}
icon={collapsed ? 'chevron-right' : 'chevron-down'}
size={ButtonSize.SMALL}
onClick={() => setCollapsed(!collapsed)}
/>
<Container style={{ alignSelf: 'center' }} padding={{ right: 'small' }}>
<Layout.Horizontal spacing="xsmall">
{!!diff.addedLines && (
<Text color={Color.GREEN_600} style={{ fontSize: '12px' }}>
+{diff.addedLines}
</Text>
)}
{!!diff.addedLines && !!diff.deletedLines && <PipeSeparator height={8} />}
{!!diff.deletedLines && (
<Text color={Color.RED_500} style={{ fontSize: '12px' }}>
-{diff.deletedLines}
</Text>
)}
</Layout.Horizontal>
</Container>
<Text inline className={css.fname}>
{diff.isDeleted ? diff.oldName : diff.isRename ? `${diff.oldName} -> ${diff.newName}` : diff.newName}
</Text>
<Button
variation={ButtonVariation.ICON}
icon={CodeIcon.Copy}
size={ButtonSize.SMALL}
tooltip={
<Container style={{ overflow: 'auto', width: 500, height: 400 }}>
<pre>{JSON.stringify(diff, null, 2)}</pre>
</Container>
}
/>
<FlexExpander />
<Container>
<label className={css.viewLabel}>
<input
type="checkbox"
value="viewed"
checked={viewed}
onChange={() => {
setViewed(!viewed)
setCollapsed(!viewed)
}}
/>
Viewed
</label>
</Container>
</Layout.Horizontal>
</Container>
<Container id={contentId} className={css.diffContent} ref={contentRef}>
{renderCustomContent && (
<Container>
<Layout.Vertical
padding="xlarge"
style={{
alignItems: 'center'
}}>
{fileDeleted && (
<Button
variation={ButtonVariation.LINK}
onClick={() => {
setRenderCustomContent(false)
setTimeout(() => renderDiffAndUpdateContainerHeightIfNeeded(true), 0)
}}>
{getString('pr.showDiff')}
</Button>
)}
<Text>{getString(fileDeleted ? 'pr.fileDeleted' : 'pr.fileUnchanged')}</Text>
</Layout.Vertical>
</Container>
)}
</Container>
</Layout.Vertical>
</Container>
)
}

View File

@ -1,66 +0,0 @@
import React, { Fragment } from 'react'
type SubstituteVars = Record<string, any> // eslint-disable-line @typescript-eslint/no-explicit-any
function translateExpression(str: string, key: string, vars: SubstituteVars) {
// Replace simple i18n expression {key|match1:value1,match2:value2}
// Sample: '{user} wants to merge {number} {number|1:commit,commits} into {target} from {source}'
// Find `{number|`
const startIndex = str.indexOf(`{${key}|`)
const MATCH_ELSE_KEY = '___'
if (startIndex !== -1) {
// Find closing `}`
const endIndex = str.indexOf('}', startIndex)
if (endIndex !== -1) {
// Get whole expression of `{number|1:commit,commits}`
const expression = str.substring(startIndex, endIndex + 1)
// Build value mapping from expression
const mapping = expression
.split('|')[1] // Get `1:commit,commits}`
.slice(0, -1) // Remove last closing `}`
.split(',') // ['1:commit', 'commits']
.reduce((map, token) => {
// Convert to a map { 1: commit, [MATCH_ELSE_KEY]: commits }
const [k, v] = token.split(':')
map[v ? k : MATCH_ELSE_KEY] = v || k
return map
}, {} as Record<string, string>)
const matchedValue = mapping[vars[key]] || mapping[MATCH_ELSE_KEY]
if (matchedValue) {
return str.replace(expression, matchedValue)
}
}
}
return str
}
export const StringSubstitute: React.FC<{
str: string
vars: SubstituteVars
}> = ({ str, vars }) => {
const re = Object.keys(vars)
.map(key => {
str = translateExpression(str, key, vars)
return `{${key}}`
})
.join('|')
return (
<>
{str
.split(new RegExp('(' + re + ')', 'gi'))
.filter(token => !!(token || '').trim())
.map((token, index) => (
<Fragment key={index}>
{token.startsWith('{') && token.endsWith('}') ? vars[token.substring(1, token.length - 1)] || token : token}
</Fragment>
))}
</>
)
}

View File

@ -7,6 +7,7 @@ export interface StringsMap {
addGitIgnore: string addGitIgnore: string
addLicense: string addLicense: string
addReadMe: string addReadMe: string
all: string
allBranches: string allBranches: string
allEvents: string allEvents: string
botAlerts: string botAlerts: string
@ -31,6 +32,7 @@ export interface StringsMap {
checkSuites: string checkSuites: string
clone: string clone: string
cloneHTTPS: string cloneHTTPS: string
closed: string
commit: string commit: string
commitChanges: string commitChanges: string
commitDirectlyTo: string commitDirectlyTo: string
@ -91,6 +93,7 @@ export interface StringsMap {
inactiveBranches: string inactiveBranches: string
individualEvents: string individualEvents: string
loading: string loading: string
merged: string
name: string name: string
nameYourBranch: string nameYourBranch: string
nameYourFile: string nameYourFile: string
@ -105,6 +108,7 @@ export interface StringsMap {
none: string none: string
ok: string ok: string
onDate: string onDate: string
open: string
optional: string optional: string
optionalExtendedDescription: string optionalExtendedDescription: string
pageNotFound: string pageNotFound: string
@ -115,10 +119,22 @@ export interface StringsMap {
'pr.buttonText': string 'pr.buttonText': string
'pr.cantMerge': string 'pr.cantMerge': string
'pr.descriptionPlaceHolder': string 'pr.descriptionPlaceHolder': string
'pr.diffStatus': string
'pr.diffView': string
'pr.failedToCreate': string 'pr.failedToCreate': string
'pr.fileDeleted': string
'pr.fileUnchanged': string
'pr.metaLine': string 'pr.metaLine': string
'pr.modalTitle': string 'pr.modalTitle': string
'pr.openBy': string
'pr.reviewChanges': string
'pr.showDiff': string
'pr.showLabel': string
'pr.showLink': string
'pr.split': string
'pr.state': string
'pr.titlePlaceHolder': string 'pr.titlePlaceHolder': string
'pr.unified': string
prefixBase: string prefixBase: string
prefixCompare: string prefixCompare: string
prev: string prev: string
@ -128,6 +144,7 @@ export interface StringsMap {
pullRequestEmpty: string pullRequestEmpty: string
pullRequests: string pullRequests: string
pushEvent: string pushEvent: string
rejected: string
renameFile: string renameFile: string
'repos.activities': string 'repos.activities': string
'repos.data': string 'repos.data': string
@ -139,6 +156,7 @@ export interface StringsMap {
repositories: string repositories: string
samplePayloadUrl: string samplePayloadUrl: string
scanAlerts: string scanAlerts: string
scrollToTop: string
search: string search: string
searchBranches: string searchBranches: string
secret: string secret: string
@ -165,4 +183,5 @@ export interface StringsMap {
webhookListingContent: string webhookListingContent: string
webhooks: string webhooks: string
yourBranches: string yourBranches: string
yours: string
} }

View File

@ -0,0 +1,19 @@
import { useCallback, useState } from 'react'
export enum UserPreference {
DIFF_VIEW_STYLE = 'DIFF_VIEW_STYLE'
}
export function useUserPreference(key: UserPreference, defaultValue: string) {
const prefKey = `CODE_MODULE_USER_PREFERENCE_${key}`
const [preference, setPreference] = useState(localStorage[prefKey] || defaultValue)
const savePreference = useCallback(
val => {
localStorage[prefKey] = val
setPreference(val)
},
[prefKey]
)
return [preference, savePreference]
}

View File

@ -161,6 +161,25 @@ pr:
modalTitle: Open a pull request modalTitle: Open a pull request
buttonText: Open pull request buttonText: Open pull request
metaLine: '{user} wants to merge {number} {number|1:commit,commits} into {target} from {source}' metaLine: '{user} wants to merge {number} {number|1:commit,commits} into {target} from {source}'
state: '{state|closed:Closed,merged:Merged,rejected:Rejected,Open}'
openBy: '#{number} opened {time} by {user}'
diffStatus: '{status|deleted:Deleted,new:Added,renamed:Renamed,copied:Copied,Changed}'
showDiff: Show Diff
fileDeleted: This file was deleted.
fileUnchanged: File without changes.
showLink: '{count} changed {count|1:file,files}'
showLabel: 'Showing {showLink} with {addedLines} {addedLines|1:addition,additions} and {deletedLines} {deletedLines|1:deletion,deletions} {config}'
diffView: Diff View
split: Split
unified: Unified
reviewChanges: Review changes
webhookListingContent: 'create,delete,deployment ...' webhookListingContent: 'create,delete,deployment ...'
general: 'General' general: 'General'
webhooks: 'Webhooks' webhooks: 'Webhooks'
open: Open
merged: Merged
closed: Closed
rejected: Rejected
yours: Yours
all: All
scrollToTop: Scroll to top

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Container, PageBody, NoDataCard, Tabs } from '@harness/uicore' import { Container, PageBody, NoDataCard, Tabs } from '@harness/uicore'
import { useHistory } from 'react-router-dom' import { useHistory } from 'react-router-dom'
import { useGet } from 'restful-react'
import { useAppContext } from 'AppContext' import { useAppContext } from 'AppContext'
import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata' import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
import { useStrings } from 'framework/strings' import { useStrings } from 'framework/strings'
@ -8,6 +9,8 @@ import { RepositoryPageHeader } from 'components/RepositoryPageHeader/Repository
import { getErrorMessage } from 'utils/Utils' import { getErrorMessage } from 'utils/Utils'
import emptyStateImage from 'images/empty-state.svg' import emptyStateImage from 'images/empty-state.svg'
import { makeDiffRefs } from 'utils/GitUtils' import { makeDiffRefs } from 'utils/GitUtils'
import { CommitsView } from 'components/CommitsView/CommitsView'
import type { RepoCommit } from 'services/code'
import { CompareContentHeader } from './CompareContentHeader/CompareContentHeader' import { CompareContentHeader } from './CompareContentHeader/CompareContentHeader'
import css from './Compare.module.scss' import css from './Compare.module.scss'
@ -18,6 +21,18 @@ export default function Compare() {
const { repoMetadata, error, loading, refetch, diffRefs } = useGetRepositoryMetadata() const { repoMetadata, error, loading, refetch, diffRefs } = useGetRepositoryMetadata()
const [sourceGitRef, setSourceGitRef] = useState(diffRefs.sourceGitRef) const [sourceGitRef, setSourceGitRef] = useState(diffRefs.sourceGitRef)
const [targetGitRef, setTargetGitRef] = useState(diffRefs.targetGitRef) const [targetGitRef, setTargetGitRef] = useState(diffRefs.targetGitRef)
const {
data: commits,
error: commitsError,
loading: commitsLoading,
refetch: commitsRefetch
} = useGet<RepoCommit[]>({
path: `/api/v1/repos/${repoMetadata?.path}/+/commits`,
queryParams: {
git_ref: sourceGitRef
},
lazy: !repoMetadata
})
return ( return (
<Container className={css.main}> <Container className={css.main}>
@ -60,22 +75,28 @@ export default function Compare() {
</Container> </Container>
))} ))}
{!!targetGitRef && !!sourceGitRef && ( {!!repoMetadata && !!targetGitRef && !!sourceGitRef && (
<Container className={css.tabsContainer} padding="xlarge"> <Container className={css.tabsContainer} padding="xlarge">
<Tabs <Tabs
id="branchesTags" id="branchesTags"
defaultSelectedTabId={'diff'} defaultSelectedTabId={'commits'}
large={false} large={false}
tabList={[ tabList={[
{ {
id: 'commits', id: 'commits',
title: getString('commits'), title: getString('commits'),
panel: <div>Commit</div> panel: commits?.length ? (
<Container padding={{ top: 'xlarge' }}>
<CommitsView commits={commits} repoMetadata={repoMetadata} />
</Container>
) : (
<></>
)
}, },
{ {
id: 'diff', id: 'diff',
title: getString('diff'), title: getString('diff'),
panel: <div>Diff</div> panel: <Container padding={{ top: 'xlarge' }}>To be defined...</Container>
} }
]} ]}
/> />

View File

@ -60,7 +60,7 @@ export function CompareContentHeader({
history.replace( history.replace(
routes.toCODEPullRequest({ routes.toCODEPullRequest({
repoPath: repoMetadata.path as string, repoPath: repoMetadata.path as string,
pullRequestId: String(data.id) pullRequestId: String(data.number)
}) })
) )
}} }}

View File

@ -9,6 +9,14 @@
.tabsContainer { .tabsContainer {
padding-top: var(--spacing-xsmall); padding-top: var(--spacing-xsmall);
// TODO: This looks bad and does not
// perform well. Need a way to fix
:global {
.Tabs--main .bp3-tab:not([aria-disabled='true']):hover .bp3-icon svg path {
fill: var(--grey-50) !important;
}
}
[role='tablist'] { [role='tablist'] {
background-color: var(--white) !important; background-color: var(--white) !important;
padding-left: var(--spacing-medium); padding-left: var(--spacing-medium);
@ -21,9 +29,10 @@
} }
[aria-selected='true'] { [aria-selected='true'] {
.tabTitle { .tabTitle,
color: var(--grey-900); .tabTitle:hover {
font-weight: 600; color: var(--grey-900) !important;
font-weight: 600 !important;
} }
} }
@ -51,3 +60,8 @@
color: var(--grey-500); color: var(--grey-500);
padding-left: var(--spacing-small); padding-left: var(--spacing-small);
} }
.tabContentContainer {
min-height: calc(100vh - 153px) !important;
background-color: var(--primary-bg) !important;
}

View File

@ -6,5 +6,6 @@ declare const styles: {
readonly tabTitle: string readonly tabTitle: string
readonly count: string readonly count: string
readonly prNumber: string readonly prNumber: string
readonly tabContentContainer: string
} }
export default styles export default styles

View File

@ -1,6 +1,7 @@
import React from 'react' import React, { useMemo } from 'react'
import { Container, PageBody, Text, FontVariation, Tabs, IconName } from '@harness/uicore' import { Container, PageBody, Text, FontVariation, Tabs, IconName } from '@harness/uicore'
import { useGet } from 'restful-react' import { useGet } from 'restful-react'
import { useHistory } from 'react-router-dom'
import { useAppContext } from 'AppContext' import { useAppContext } from 'AppContext'
import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata' import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
import { useStrings } from 'framework/strings' import { useStrings } from 'framework/strings'
@ -8,16 +9,30 @@ import { RepositoryPageHeader } from 'components/RepositoryPageHeader/Repository
import { getErrorMessage } from 'utils/Utils' import { getErrorMessage } from 'utils/Utils'
import type { PullRequestResponse } from 'utils/types' import type { PullRequestResponse } from 'utils/types'
import { CodeIcon } from 'utils/GitUtils' import { CodeIcon } from 'utils/GitUtils'
import { PullRequestMetadataInfo } from './PullRequestMetadataInfo' import { PullRequestMetaLine } from './PullRequestMetaLine'
import { PullRequestConversation } from './PullRequestConversation/PullRequestConversation' import { PullRequestConversation } from './PullRequestConversation/PullRequestConversation'
import { PullRequestDiff } from './PullRequestDiff/PullRequestDiff' import { PullRequestDiff } from './PullRequestDiff/PullRequestDiff'
import { PullRequestCommits } from './PullRequestCommits/PullRequestCommits' import { PullRequestCommits } from './PullRequestCommits/PullRequestCommits'
import css from './PullRequest.module.scss' import css from './PullRequest.module.scss'
enum PullRequestSection {
CONVERSATION = 'conversation',
COMMITS = 'commits',
DIFFS = 'diffs'
}
export default function PullRequest() { export default function PullRequest() {
const history = useHistory()
const { getString } = useStrings() const { getString } = useStrings()
const { routes } = useAppContext() const { routes } = useAppContext()
const { repoMetadata, error, loading, refetch, pullRequestId } = useGetRepositoryMetadata() const {
repoMetadata,
error,
loading,
refetch,
pullRequestId,
pullRequestSection = PullRequestSection.CONVERSATION
} = useGetRepositoryMetadata()
const { const {
data: prData, data: prData,
error: prError, error: prError,
@ -26,6 +41,13 @@ export default function PullRequest() {
path: `/api/v1/repos/${repoMetadata?.path}/+/pullreq/${pullRequestId}`, path: `/api/v1/repos/${repoMetadata?.path}/+/pullreq/${pullRequestId}`,
lazy: !repoMetadata lazy: !repoMetadata
}) })
const activeTab = useMemo(
() =>
Object.values(PullRequestSection).find(value => value === pullRequestSection)
? pullRequestSection
: PullRequestSection.CONVERSATION,
[pullRequestSection]
)
return ( return (
<Container className={css.main}> <Container className={css.main}>
@ -46,26 +68,35 @@ export default function PullRequest() {
{repoMetadata ? ( {repoMetadata ? (
prData ? ( prData ? (
<> <>
<PullRequestMetadataInfo repoMetadata={repoMetadata} {...prData} /> <PullRequestMetaLine repoMetadata={repoMetadata} {...prData} />
<Container className={css.tabsContainer}> <Container className={css.tabsContainer}>
<Tabs <Tabs
id="pullRequestTabs" id="pullRequestTabs"
defaultSelectedTabId={'conversation'} defaultSelectedTabId={activeTab}
large={false} large={false}
onChange={tabId => {
history.replace(
routes.toCODEPullRequest({
repoPath: repoMetadata.path as string,
pullRequestId,
pullRequestSection: tabId !== PullRequestSection.CONVERSATION ? (tabId as string) : undefined
})
)
}}
tabList={[ tabList={[
{ {
id: 'conversation', id: PullRequestSection.CONVERSATION,
title: <TabTitle icon={CodeIcon.Chat} title={getString('conversation')} count={100} />, title: <TabTitle icon={CodeIcon.Chat} title={getString('conversation')} count={100} />,
panel: <PullRequestConversation repoMetadata={repoMetadata} pullRequestMetadata={prData} /> panel: <PullRequestConversation repoMetadata={repoMetadata} pullRequestMetadata={prData} />
}, },
{ {
id: 'commits', id: PullRequestSection.COMMITS,
title: <TabTitle icon={CodeIcon.Commit} title={getString('commits')} count={15} />, title: <TabTitle icon={CodeIcon.Commit} title={getString('commits')} count={15} />,
panel: <PullRequestCommits repoMetadata={repoMetadata} pullRequestMetadata={prData} /> panel: <PullRequestCommits repoMetadata={repoMetadata} pullRequestMetadata={prData} />
}, },
{ {
id: 'diff', id: PullRequestSection.DIFFS,
title: <TabTitle icon={CodeIcon.Commit} title={getString('diff')} count={20} />, title: <TabTitle icon={CodeIcon.File} title={getString('diff')} count={20} />,
panel: <PullRequestDiff repoMetadata={repoMetadata} pullRequestMetadata={prData} /> panel: <PullRequestDiff repoMetadata={repoMetadata} pullRequestMetadata={prData} />
} }
]} ]}
@ -87,7 +118,7 @@ const PullRequestTitle: React.FC<PullRequestResponse> = ({ title, number }) => (
const TabTitle: React.FC<{ icon: IconName; title: string; count?: number }> = ({ icon, title, count }) => ( const TabTitle: React.FC<{ icon: IconName; title: string; count?: number }> = ({ icon, title, count }) => (
<Text icon={icon} className={css.tabTitle}> <Text icon={icon} className={css.tabTitle}>
{title}{' '} {title}
{!!count && ( {!!count && (
<Text inline className={css.count}> <Text inline className={css.count}>
{count} {count}

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

@ -1,20 +1,30 @@
import React from 'react' import React from 'react'
import { Container } from '@harness/uicore' import { useGet } from 'restful-react'
import { useAppContext } from 'AppContext' import type { RepoCommit } from 'services/code'
import { useStrings } from 'framework/strings'
import type { GitInfoProps } from 'utils/GitUtils' import type { GitInfoProps } from 'utils/GitUtils'
import css from './PullRequestCommits.module.scss' import { CommitsView } from 'components/CommitsView/CommitsView'
import { PullRequestTabContentWrapper } from '../PullRequestTabContentWrapper'
export const PullRequestCommits: React.FC<Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'>> = ({ export const PullRequestCommits: React.FC<Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'>> = ({
repoMetadata, repoMetadata,
pullRequestMetadata pullRequestMetadata
}) => { }) => {
const { getString } = useStrings() const {
const { routes } = useAppContext() data: commits,
error,
loading,
refetch
} = useGet<RepoCommit[]>({
path: `/api/v1/repos/${repoMetadata?.path}/+/commits`,
queryParams: {
git_ref: pullRequestMetadata.sourceBranch
},
lazy: !repoMetadata
})
return ( return (
<Container className={css.main} padding="xlarge"> <PullRequestTabContentWrapper loading={loading} error={error} onRetry={() => refetch()}>
COMMITS... {!!commits?.length && <CommitsView commits={commits} repoMetadata={repoMetadata} />}
</Container> </PullRequestTabContentWrapper>
) )
} }

View File

@ -1,20 +1,18 @@
import React from 'react' import React from 'react'
import { Container } from '@harness/uicore' import { Container } from '@harness/uicore'
import { useAppContext } from 'AppContext'
import { useStrings } from 'framework/strings'
import type { GitInfoProps } from 'utils/GitUtils' import type { GitInfoProps } from 'utils/GitUtils'
import css from './PullRequestConversation.module.scss' import { MarkdownViewer } from 'components/SourceCodeViewer/SourceCodeViewer'
import { PullRequestTabContentWrapper } from '../PullRequestTabContentWrapper'
export const PullRequestConversation: React.FC<Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'>> = ({ export const PullRequestConversation: React.FC<Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'>> = ({
repoMetadata, repoMetadata,
pullRequestMetadata pullRequestMetadata
}) => { }) => {
const { getString } = useStrings()
const { routes } = useAppContext()
return ( return (
<Container className={css.main} padding="xlarge"> <PullRequestTabContentWrapper loading={undefined} error={undefined} onRetry={() => {}}>
CONVERSATION... <Container>
</Container> <MarkdownViewer source={pullRequestMetadata.description} />
</Container>
</PullRequestTabContentWrapper>
) )
} }

View File

@ -1,2 +1,52 @@
.main { .wrapper {
padding-top: 0 !important;
}
.header {
position: sticky;
top: 0;
padding: var(--spacing-medium) 0 !important;
background-color: var(--white) !important;
z-index: 2;
}
.showLabel {
font-size: 12px !important;
font-weight: 600 !important;
color: var(--grey-700) !important;
.showLabelLink {
all: unset !important;
height: auto !important;
min-height: auto !important;
padding: 0 !important;
font-size: 12px !important;
font-weight: 600 !important;
color: var(--grey-700) !important;
color: var(--primary-7) !important;
display: inline-block !important;
margin: 0 4px !important;
cursor: pointer !important;
}
}
.layout {
border-radius: 5px;
}
.filesMenu {
max-height: 350px;
overflow: auto;
padding: var(--spacing-xsmall) !important;
:global {
.bp3-menu-item:hover {
background-color: rgba(167, 182, 194, 0.3);
color: inherit;
}
}
.menuItem {
align-items: center;
}
} }

View File

@ -0,0 +1,12 @@
/* eslint-disable */
// this is an auto-generated file
declare const styles: {
readonly wrapper: string
readonly header: string
readonly showLabel: string
readonly showLabelLink: string
readonly layout: string
readonly filesMenu: string
readonly menuItem: string
}
export default styles

View File

@ -1,20 +1,227 @@
import React from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { Container } from '@harness/uicore' import {
import { useAppContext } from 'AppContext' Container,
FlexExpander,
ButtonVariation,
Layout,
Text,
StringSubstitute,
FontVariation,
Button,
Icon,
Color
} from '@harness/uicore'
import { ButtonGroup, Button as BButton, Classes, Menu, MenuItem } from '@blueprintjs/core'
import { noop } from 'lodash-es'
import cx from 'classnames'
import * as Diff2Html from 'diff2html'
// import { useAppContext } from 'AppContext'
// import { useStrings } from 'framework/strings'
import 'highlight.js/styles/github.css'
import 'diff2html/bundles/css/diff2html.min.css'
import type { DiffFile } from 'diff2html/lib/types'
import { useStrings } from 'framework/strings' import { useStrings } from 'framework/strings'
import type { GitInfoProps } from 'utils/GitUtils' import { CodeIcon, GitInfoProps } from 'utils/GitUtils'
import { ButtonRoleProps, formatNumber, waitUntil } from 'utils/Utils'
import { DiffViewer, DIFF2HTML_CONFIG, DiffViewStyle } from 'components/DiffViewer/DiffViewer'
import { UserPreference, useUserPreference } from 'hooks/useUserPreference'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import { PullRequestTabContentWrapper } from '../PullRequestTabContentWrapper'
import diffExample from 'raw-loader!./example2.diff'
import css from './PullRequestDiff.module.scss' import css from './PullRequestDiff.module.scss'
export const PullRequestDiff: React.FC<Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'>> = ({ const STICKY_TOP_POSITION = 64
repoMetadata,
pullRequestMetadata export const PullRequestDiff: React.FC<Pick<GitInfoProps, 'repoMetadata' | 'pullRequestMetadata'>> = () => {
}) => {
const { getString } = useStrings() const { getString } = useStrings()
const { routes } = useAppContext() const [viewStyle, setViewStyle] = useUserPreference(UserPreference.DIFF_VIEW_STYLE, DiffViewStyle.SPLIT)
const [diffs, setDiffs] = useState<DiffFile[]>([])
const [stickyInAction, setStickyInAction] = useState(false)
const diffStats = useMemo(() => {
return (diffs || []).reduce(
(obj, diff) => {
obj.addedLines += diff.addedLines
obj.deletedLines += diff.deletedLines
return obj
},
{ addedLines: 0, deletedLines: 0 }
)
}, [diffs])
useEffect(() => {
setDiffs(Diff2Html.parse(diffExample, DIFF2HTML_CONFIG))
}, [])
useEffect(() => {
const onScroll = () => {
if (window.scrollY >= 150) {
if (!stickyInAction) {
setStickyInAction(true)
}
} else {
if (stickyInAction) {
setStickyInAction(false)
}
}
}
window.addEventListener('scroll', onScroll)
return () => {
window.removeEventListener('scroll', onScroll)
}
}, [stickyInAction])
// console.log({ diffs, viewStyle })
return ( return (
<Container className={css.main} padding="xlarge"> <PullRequestTabContentWrapper loading={undefined} error={undefined} onRetry={noop} className={css.wrapper}>
DIFF <Container className={css.header}>
</Container> <Layout.Horizontal>
<Container flex={{ alignItems: 'center' }}>
<Text icon="accordion-collapsed" iconProps={{ size: 12 }} className={css.showLabel}>
<StringSubstitute
str={getString('pr.showLabel')}
vars={{
showLink: (
<Button
variation={ButtonVariation.LINK}
className={css.showLabelLink}
tooltip={
<Menu className={css.filesMenu}>
{diffs?.map((diff, index) => (
<MenuItem
key={index}
className={css.menuItem}
icon={<Icon name={CodeIcon.File} padding={{ right: 'xsmall' }} />}
labelElement={
<Layout.Horizontal spacing="xsmall">
{!!diff.addedLines && (
<Text color={Color.GREEN_600} style={{ fontSize: '12px' }}>
+{diff.addedLines}
</Text>
)}
{!!diff.addedLines && !!diff.deletedLines && <PipeSeparator height={8} />}
{!!diff.deletedLines && (
<Text color={Color.RED_500} style={{ fontSize: '12px' }}>
-{diff.deletedLines}
</Text>
)}
</Layout.Horizontal>
}
text={
diff.isDeleted
? diff.oldName
: diff.isRename
? `${diff.oldName} -> ${diff.newName}`
: diff.newName
}
onClick={() => {
const containerDOM = document.getElementById(`file-diff-container-${index}`)
if (containerDOM) {
containerDOM.scrollIntoView()
waitUntil(
() => !!containerDOM.querySelector('[data-rendered="true"]'),
() => {
containerDOM.scrollIntoView()
// Fix scrolling position messes up with sticky header
const { y } = containerDOM.getBoundingClientRect()
if (y - STICKY_TOP_POSITION < 1) {
if (STICKY_TOP_POSITION) {
window.scroll({ top: window.scrollY - STICKY_TOP_POSITION })
}
}
}
)
}
}}
/>
))}
</Menu>
}
tooltipProps={{ interactionKind: 'click', hasBackdrop: true }}>
<StringSubstitute str={getString('pr.showLink')} vars={{ count: diffs?.length || 0 }} />
</Button>
),
addedLines: formatNumber(diffStats.addedLines),
deletedLines: formatNumber(diffStats.deletedLines),
config: (
<Text
icon="cog"
rightIcon="caret-down"
tooltip={
<Container padding="large">
<Layout.Horizontal spacing="xsmall" flex={{ alignItems: 'center' }}>
<Text width={100} font={{ variation: FontVariation.SMALL_BOLD }}>
{getString('pr.diffView')}
</Text>
<ButtonGroup>
<BButton
className={cx(
Classes.POPOVER_DISMISS,
viewStyle === DiffViewStyle.SPLIT ? Classes.ACTIVE : ''
)}
onClick={() => {
setViewStyle(DiffViewStyle.SPLIT)
window.scroll({ top: 0 })
}}>
{getString('pr.split')}
</BButton>
<BButton
className={cx(
Classes.POPOVER_DISMISS,
viewStyle === DiffViewStyle.UNIFIED ? Classes.ACTIVE : ''
)}
onClick={() => {
setViewStyle(DiffViewStyle.UNIFIED)
window.scroll({ top: 0 })
}}>
{getString('pr.unified')}
</BButton>
</ButtonGroup>
</Layout.Horizontal>
</Container>
}
iconProps={{ size: 14, padding: { right: 3 } }}
rightIconProps={{ size: 13, padding: { left: 0 } }}
padding={{ left: 'small' }}
{...ButtonRoleProps}
/>
)
}}
/>
</Text>
{stickyInAction && (
<Layout.Horizontal padding={{ left: 'small' }}>
<PipeSeparator height={10} />
<Button
variation={ButtonVariation.ICON}
icon="arrow-up"
iconProps={{ size: 14 }}
onClick={() => window.scroll({ top: 0 })}
tooltip={getString('scrollToTop')}
tooltipProps={{ isDark: true }}
/>
</Layout.Horizontal>
)}
</Container>
<FlexExpander />
<Button text={getString('pr.reviewChanges')} variation={ButtonVariation.PRIMARY} intent="success" />
</Layout.Horizontal>
</Container>
<Layout.Vertical spacing="medium" className={css.layout}>
{diffs?.map((diff, index) => (
// Note: `key={viewStyle + index}` will reset DiffView when viewStyle
// is changed. Making it easier to control states inside DiffView itself, as it does not have to deal with viewStyle
<DiffViewer
key={viewStyle + index}
index={index}
diff={diff}
viewStyle={viewStyle}
stickyTopPosition={STICKY_TOP_POSITION}
/>
))}
</Layout.Vertical>
</PullRequestTabContentWrapper>
) )
} }

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,358 @@
diff --git a/README.md b/README.md
index 132c8a28..46909f25 100644
--- a/README.md
+++ b/README.md
@@ -98,6 +98,9 @@ The HTML output accepts a Javascript object with configuration. Possible options
- `synchronisedScroll`: scroll both panes in side-by-side mode: `true` or `false`, default is `false`
- `matchWordsThreshold`: similarity threshold for word matching, default is 0.25
- `matchingMaxComparisons`: perform at most this much comparisons for line matching a block of changes, default is `2500`
+ - `templates`: object with previously compiled templates to replace parts of the html
+ - `rawTemplates`: object with raw not compiled templates to replace parts of the html
+ > For more information regarding the possible templates look into [src/templates](https://github.com/rtfpessoa/diff2html/tree/master/src/templates)
## Diff2HtmlUI Helper
diff --git a/scripts/hulk.js b/scripts/hulk.js
index 5a793c18..a4b1a4d5 100755
--- a/scripts/hulk.js
+++ b/scripts/hulk.js
@@ -173,11 +173,11 @@ function namespace(name) {
// write a template foreach file that matches template extension
templates = extractFiles(options.argv.remain)
.map(function(file) {
- var openedFile = fs.readFileSync(file, 'utf-8');
+ var openedFile = fs.readFileSync(file, 'utf-8').trim();
var name;
if (!openedFile) return;
name = namespace(path.basename(file).replace(/\..*$/, ''));
- openedFile = removeByteOrderMark(openedFile.trim());
+ openedFile = removeByteOrderMark(openedFile);
openedFile = wrap(file, name, openedFile);
if (!options.outputdir) return openedFile;
fs.writeFileSync(path.join(options.outputdir, name + '.js')
diff --git a/src/diff2html.js b/src/diff2html.js
index 21b0119e..64e138f5 100644
--- a/src/diff2html.js
+++ b/src/diff2html.js
@@ -7,7 +7,6 @@
(function() {
var diffParser = require('./diff-parser.js').DiffParser;
- var fileLister = require('./file-list-printer.js').FileListPrinter;
var htmlPrinter = require('./html-printer.js').HtmlPrinter;
function Diff2Html() {
@@ -43,7 +42,7 @@
var fileList = '';
if (configOrEmpty.showFiles === true) {
- fileList = fileLister.generateFileList(diffJson, configOrEmpty);
+ fileList = htmlPrinter.generateFileListSummary(diffJson, configOrEmpty);
}
var diffOutput = '';
diff --git a/src/file-list-printer.js b/src/file-list-printer.js
index e408d9b2..1e0a2c61 100644
--- a/src/file-list-printer.js
+++ b/src/file-list-printer.js
@@ -8,11 +8,16 @@
(function() {
var printerUtils = require('./printer-utils.js').PrinterUtils;
- var hoganUtils = require('./hoganjs-utils.js').HoganJsUtils;
+ var hoganUtils;
+
var baseTemplatesPath = 'file-summary';
var iconsBaseTemplatesPath = 'icon';
- function FileListPrinter() {
+ function FileListPrinter(config) {
+ this.config = config;
+
+ var HoganJsUtils = require('./hoganjs-utils.js').HoganJsUtils;
+ hoganUtils = new HoganJsUtils(config);
}
FileListPrinter.prototype.generateFileList = function(diffFiles) {
@@ -38,5 +43,5 @@
});
};
- module.exports.FileListPrinter = new FileListPrinter();
+ module.exports.FileListPrinter = FileListPrinter;
})();
diff --git a/src/hoganjs-utils.js b/src/hoganjs-utils.js
index 9949e5fa..b2e9c275 100644
--- a/src/hoganjs-utils.js
+++ b/src/hoganjs-utils.js
@@ -8,18 +8,26 @@
(function() {
var fs = require('fs');
var path = require('path');
-
var hogan = require('hogan.js');
var hoganTemplates = require('./templates/diff2html-templates.js');
- var templatesPath = path.resolve(__dirname, 'templates');
+ var extraTemplates;
+
+ function HoganJsUtils(configuration) {
+ this.config = configuration || {};
+ extraTemplates = this.config.templates || {};
- function HoganJsUtils() {
+ var rawTemplates = this.config.rawTemplates || {};
+ for (var templateName in rawTemplates) {
+ if (rawTemplates.hasOwnProperty(templateName)) {
+ if (!extraTemplates[templateName]) extraTemplates[templateName] = this.compile(rawTemplates[templateName]);
+ }
+ }
}
- HoganJsUtils.prototype.render = function(namespace, view, params, configuration) {
- var template = this.template(namespace, view, configuration);
+ HoganJsUtils.prototype.render = function(namespace, view, params) {
+ var template = this.template(namespace, view);
if (template) {
return template.render(params);
}
@@ -27,17 +35,16 @@
return null;
};
- HoganJsUtils.prototype.template = function(namespace, view, configuration) {
- var config = configuration || {};
+ HoganJsUtils.prototype.template = function(namespace, view) {
var templateKey = this._templateKey(namespace, view);
- return this._getTemplate(templateKey, config);
+ return this._getTemplate(templateKey);
};
- HoganJsUtils.prototype._getTemplate = function(templateKey, config) {
+ HoganJsUtils.prototype._getTemplate = function(templateKey) {
var template;
- if (!config.noCache) {
+ if (!this.config.noCache) {
template = this._readFromCache(templateKey);
}
@@ -53,6 +60,7 @@
try {
if (fs.readFileSync) {
+ var templatesPath = path.resolve(__dirname, 'templates');
var templatePath = path.join(templatesPath, templateKey);
var templateContent = fs.readFileSync(templatePath + '.mustache', 'utf8');
template = hogan.compile(templateContent);
@@ -66,12 +74,16 @@
};
HoganJsUtils.prototype._readFromCache = function(templateKey) {
- return hoganTemplates[templateKey];
+ return extraTemplates[templateKey] || hoganTemplates[templateKey];
};
HoganJsUtils.prototype._templateKey = function(namespace, view) {
return namespace + '-' + view;
};
- module.exports.HoganJsUtils = new HoganJsUtils();
+ HoganJsUtils.prototype.compile = function(templateStr) {
+ return hogan.compile(templateStr);
+ };
+
+ module.exports.HoganJsUtils = HoganJsUtils;
})();
diff --git a/src/html-printer.js b/src/html-printer.js
index 585d5b66..13f83047 100644
--- a/src/html-printer.js
+++ b/src/html-printer.js
@@ -8,6 +8,7 @@
(function() {
var LineByLinePrinter = require('./line-by-line-printer.js').LineByLinePrinter;
var SideBySidePrinter = require('./side-by-side-printer.js').SideBySidePrinter;
+ var FileListPrinter = require('./file-list-printer.js').FileListPrinter;
function HtmlPrinter() {
}
@@ -22,5 +23,10 @@
return sideBySidePrinter.generateSideBySideJsonHtml(diffFiles);
};
+ HtmlPrinter.prototype.generateFileListSummary = function(diffJson, config) {
+ var fileListPrinter = new FileListPrinter(config);
+ return fileListPrinter.generateFileList(diffJson);
+ };
+
module.exports.HtmlPrinter = new HtmlPrinter();
})();
diff --git a/src/line-by-line-printer.js b/src/line-by-line-printer.js
index b07eb53c..d230bedd 100644
--- a/src/line-by-line-printer.js
+++ b/src/line-by-line-printer.js
@@ -11,7 +11,8 @@
var utils = require('./utils.js').Utils;
var Rematch = require('./rematch.js').Rematch;
- var hoganUtils = require('./hoganjs-utils.js').HoganJsUtils;
+ var hoganUtils;
+
var genericTemplatesPath = 'generic';
var baseTemplatesPath = 'line-by-line';
var iconsBaseTemplatesPath = 'icon';
@@ -19,6 +20,9 @@
function LineByLinePrinter(config) {
this.config = config;
+
+ var HoganJsUtils = require('./hoganjs-utils.js').HoganJsUtils;
+ hoganUtils = new HoganJsUtils(config);
}
LineByLinePrinter.prototype.makeFileDiffHtml = function(file, diffs) {
diff --git a/src/side-by-side-printer.js b/src/side-by-side-printer.js
index bbf1dc8d..5e3033b3 100644
--- a/src/side-by-side-printer.js
+++ b/src/side-by-side-printer.js
@@ -11,7 +11,8 @@
var utils = require('./utils.js').Utils;
var Rematch = require('./rematch.js').Rematch;
- var hoganUtils = require('./hoganjs-utils.js').HoganJsUtils;
+ var hoganUtils;
+
var genericTemplatesPath = 'generic';
var baseTemplatesPath = 'side-by-side';
var iconsBaseTemplatesPath = 'icon';
@@ -26,6 +27,9 @@
function SideBySidePrinter(config) {
this.config = config;
+
+ var HoganJsUtils = require('./hoganjs-utils.js').HoganJsUtils;
+ hoganUtils = new HoganJsUtils(config);
}
SideBySidePrinter.prototype.makeDiffHtml = function(file, diffs) {
diff --git a/test/file-list-printer-tests.js b/test/file-list-printer-tests.js
index a502a46f..60ea3208 100644
--- a/test/file-list-printer-tests.js
+++ b/test/file-list-printer-tests.js
@@ -1,6 +1,6 @@
var assert = require('assert');
-var fileListPrinter = require('../src/file-list-printer.js').FileListPrinter;
+var fileListPrinter = new (require('../src/file-list-printer.js').FileListPrinter)();
describe('FileListPrinter', function() {
describe('generateFileList', function() {
diff --git a/test/hogan-cache-tests.js b/test/hogan-cache-tests.js
index 190bf6f8..a34839c0 100644
--- a/test/hogan-cache-tests.js
+++ b/test/hogan-cache-tests.js
@@ -1,6 +1,6 @@
var assert = require('assert');
-var HoganJsUtils = require('../src/hoganjs-utils.js').HoganJsUtils;
+var HoganJsUtils = new (require('../src/hoganjs-utils.js').HoganJsUtils)();
var diffParser = require('../src/diff-parser.js').DiffParser;
describe('HoganJsUtils', function() {
@@ -21,16 +21,50 @@ describe('HoganJsUtils', function() {
});
assert.equal(emptyDiffHtml, result);
});
+
it('should render view without cache', function() {
var result = HoganJsUtils.render('generic', 'empty-diff', {
contentClass: 'd2h-code-line',
diffParser: diffParser
}, {noCache: true});
- assert.equal(emptyDiffHtml + '\n', result);
+ assert.equal(emptyDiffHtml, result);
});
+
it('should return null if template is missing', function() {
- var result = HoganJsUtils.render('generic', 'missing-template', {}, {noCache: true});
+ var hoganUtils = new (require('../src/hoganjs-utils.js').HoganJsUtils)({noCache: true});
+ var result = hoganUtils.render('generic', 'missing-template', {});
assert.equal(null, result);
});
+
+ it('should allow templates to be overridden with compiled templates', function() {
+ var emptyDiffTemplate = HoganJsUtils.compile('<p>{{myName}}</p>');
+
+ var config = {templates: {'generic-empty-diff': emptyDiffTemplate}};
+ var hoganUtils = new (require('../src/hoganjs-utils.js').HoganJsUtils)(config);
+ var result = hoganUtils.render('generic', 'empty-diff', {myName: 'Rodrigo Fernandes'});
+ assert.equal('<p>Rodrigo Fernandes</p>', result);
+ });
+
+ it('should allow templates to be overridden with uncompiled templates', function() {
+ var emptyDiffTemplate = '<p>{{myName}}</p>';
+
+ var config = {rawTemplates: {'generic-empty-diff': emptyDiffTemplate}};
+ var hoganUtils = new (require('../src/hoganjs-utils.js').HoganJsUtils)(config);
+ var result = hoganUtils.render('generic', 'empty-diff', {myName: 'Rodrigo Fernandes'});
+ assert.equal('<p>Rodrigo Fernandes</p>', result);
+ });
+
+ it('should allow templates to be overridden giving priority to compiled templates', function() {
+ var emptyDiffTemplate = HoganJsUtils.compile('<p>{{myName}}</p>');
+ var emptyDiffTemplateUncompiled = '<p>Not used!</p>';
+
+ var config = {
+ templates: {'generic-empty-diff': emptyDiffTemplate},
+ rawTemplates: {'generic-empty-diff': emptyDiffTemplateUncompiled}
+ };
+ var hoganUtils = new (require('../src/hoganjs-utils.js').HoganJsUtils)(config);
+ var result = hoganUtils.render('generic', 'empty-diff', {myName: 'Rodrigo Fernandes'});
+ assert.equal('<p>Rodrigo Fernandes</p>', result);
+ });
});
});
diff --git a/test/line-by-line-tests.js b/test/line-by-line-tests.js
index 1cd92073..8869b3df 100644
--- a/test/line-by-line-tests.js
+++ b/test/line-by-line-tests.js
@@ -14,7 +14,7 @@ describe('LineByLinePrinter', function() {
' File without changes\n' +
' </div>\n' +
' </td>\n' +
- '</tr>\n';
+ '</tr>';
assert.equal(expected, fileHtml);
});
@@ -422,7 +422,6 @@ describe('LineByLinePrinter', function() {
' </div>\n' +
' </td>\n' +
'</tr>\n' +
- '\n' +
' </tbody>\n' +
' </table>\n' +
' </div>\n' +
diff --git a/test/side-by-side-printer-tests.js b/test/side-by-side-printer-tests.js
index 76625f8e..771daaa5 100644
--- a/test/side-by-side-printer-tests.js
+++ b/test/side-by-side-printer-tests.js
@@ -14,7 +14,7 @@ describe('SideBySidePrinter', function() {
' File without changes\n' +
' </div>\n' +
' </td>\n' +
- '</tr>\n';
+ '</tr>';
assert.equal(expectedRight, fileHtml.right);
assert.equal(expectedLeft, fileHtml.left);
@@ -324,7 +324,6 @@ describe('SideBySidePrinter', function() {
' </div>\n' +
' </td>\n' +
'</tr>\n' +
- '\n' +
' </tbody>\n' +
' </table>\n' +
' </div>\n' +

View File

@ -0,0 +1,43 @@
.main {
.state {
--color: var(--green-700) !important;
--bg: var(--green-50) !important;
color: var(--color);
background-color: var(--bg);
font-size: 10px !important;
font-weight: 600 !important;
padding: 2px 6px !important;
border-radius: 4px;
&.merged {
--color: var(--purple-700) !important;
--bg: var(--purple-50) !important;
}
&.closed {
--color: var(--grey-700) !important;
--bg: var(--grey-100) !important;
}
&.rejected {
--color: var(--red-700) !important;
--bg: var(--red-50) !important;
}
}
.metaline {
font-size: 12px !important;
font-weight: 500 !important;
line-height: 20px !important;
color: var(--grey-500) !important;
&.time {
color: var(--grey-400) !important;
}
strong {
color: var(--grey-700) !important;
}
}
}

View File

@ -3,6 +3,9 @@
declare const styles: { declare const styles: {
readonly main: string readonly main: string
readonly state: string readonly state: string
readonly merged: string
readonly closed: string
readonly rejected: string
readonly metaline: string readonly metaline: string
readonly time: string readonly time: string
} }

View File

@ -0,0 +1,87 @@
import React from 'react'
import { Container, Text, Layout, Color, StringSubstitute, IconName } from '@harness/uicore'
import cx from 'classnames'
import ReactTimeago from 'react-timeago'
import { CodeIcon, GitInfoProps, PullRequestState } from 'utils/GitUtils'
import { useAppContext } from 'AppContext'
import { useStrings } from 'framework/strings'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import { GitRefLink } from 'components/GitRefLink/GitRefLink'
import type { PullRequestResponse } from 'utils/types'
import css from './PullRequestMetaLine.module.scss'
export const PullRequestMetaLine: React.FC<PullRequestResponse & Pick<GitInfoProps, 'repoMetadata'>> = ({
repoMetadata,
targetBranch,
sourceBranch,
createdBy = '',
updated,
merged,
state
}) => {
const { getString } = useStrings()
const { routes } = useAppContext()
const vars = {
user: <strong>{createdBy}</strong>,
number: <strong>5</strong>, // TODO: No data from backend now
target: (
<GitRefLink
text={targetBranch}
url={routes.toCODERepository({ repoPath: repoMetadata.path as string, gitRef: targetBranch })}
/>
),
source: (
<GitRefLink
text={sourceBranch}
url={routes.toCODERepository({ repoPath: repoMetadata.path as string, gitRef: sourceBranch })}
/>
)
}
return (
<Container padding={{ left: 'xlarge' }} className={css.main}>
<Layout.Horizontal spacing="small">
<PullRequestStateLabel state={merged ? PullRequestState.MERGED : (state as PullRequestState)} />
<Text className={css.metaline}>
<StringSubstitute str={getString('pr.metaLine')} vars={vars} />
</Text>
<PipeSeparator height={9} />
<Text inline className={cx(css.metaline, css.time)}>
<ReactTimeago date={updated} />
</Text>
</Layout.Horizontal>
</Container>
)
}
const PullRequestStateLabel: React.FC<{ state: PullRequestState }> = ({ state }) => {
const { getString } = useStrings()
let color = Color.GREEN_700
let icon: IconName = CodeIcon.PullRequest
let clazz: typeof css | string = ''
switch (state) {
case PullRequestState.MERGED:
color = Color.PURPLE_700
icon = CodeIcon.PullRequest
clazz = css.merged
break
case PullRequestState.CLOSED:
color = Color.GREY_600
icon = CodeIcon.PullRequest
clazz = css.closed
break
case PullRequestState.REJECTED:
color = Color.RED_600
icon = CodeIcon.PullRequestRejected
clazz = css.rejected
break
}
return (
<Text className={cx(css.state, clazz)} icon={icon} iconProps={{ color, size: 9 }}>
<StringSubstitute str={getString('pr.state')} vars={{ state }} />
</Text>
)
}

View File

@ -1,25 +0,0 @@
.main {
.state {
color: var(--green-700) !important;
background-color: var(--green-50) !important;
font-size: 9px !important;
font-weight: 600 !important;
padding: 2px 6px !important;
border-radius: 4px;
}
.metaline {
font-size: 12px !important;
font-weight: 500 !important;
line-height: 20px !important;
color: var(--grey-500) !important;
&.time {
color: var(--grey-400) !important;
}
strong {
color: var(--grey-700) !important;
}
}
}

View File

@ -1,46 +0,0 @@
import React from 'react'
import { Container, Text, Layout, Color } from '@harness/uicore'
import cx from 'classnames'
import ReactTimeago from 'react-timeago'
import { CodeIcon, GitInfoProps } from 'utils/GitUtils'
// import { useAppContext } from 'AppContext'
import { useStrings } from 'framework/strings'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import { StringSubstitute } from 'components/StringSubstitute/StringSubstitute'
import { GitRefLink } from 'components/GitRefLink/GitRefLink'
import type { PullRequestResponse } from 'utils/types'
import css from './PullRequestMetadataInfo.module.scss'
export const PullRequestMetadataInfo: React.FC<PullRequestResponse & Pick<GitInfoProps, 'repoMetadata'>> = ({
createdBy = '',
targetBranch,
sourceBranch,
updated
// repoMetadata
}) => {
const { getString } = useStrings()
// const { routes } = useAppContext()
const vars = {
user: <strong>{createdBy}</strong>,
number: 1, // TODO: No data from backend now
target: <GitRefLink text={targetBranch} url="TODO" />,
source: <GitRefLink text={sourceBranch} url="TODO" />
}
return (
<Container padding={{ left: 'xlarge' }} className={css.main}>
<Layout.Horizontal spacing="small">
<Text className={css.state} icon={CodeIcon.PullRequest} iconProps={{ color: Color.GREEN_700, size: 9 }}>
Open
</Text>
<Text className={css.metaline}>
<StringSubstitute str={getString('pr.metaLine')} vars={vars} />
</Text>
<PipeSeparator height={9} />
<Text inline className={cx(css.metaline, css.time)}>
<ReactTimeago date={updated} />
</Text>
</Layout.Horizontal>
</Container>
)
}

View File

@ -0,0 +1,30 @@
import React from 'react'
import { Container, PageError } from '@harness/uicore'
import cx from 'classnames'
import { ContainerSpinner } from 'components/ContainerSpinner/ContainerSpinner'
import { getErrorMessage } from 'utils/Utils'
import css from './PullRequest.module.scss'
interface PullRequestTabContentWrapperProps {
className?: string
loading?: boolean
error?: Unknown
onRetry: () => void
}
export const PullRequestTabContentWrapper: React.FC<PullRequestTabContentWrapperProps> = ({
className,
loading,
error,
onRetry,
children
}) => (
<Container
className={cx(css.tabContentContainer, className)}
padding="xlarge"
{...(!!loading || !!error ? { flex: true } : {})}>
{loading && <ContainerSpinner />}
{error && <PageError message={getErrorMessage(error)} onClick={onRetry} />}
{!loading && !error && children}
</Container>
)

View File

@ -1,4 +1,16 @@
.main { .main {
min-height: var(--page-min-height, 100%); min-height: var(--page-min-height, 100%);
background-color: var(--primary-bg) !important; background-color: var(--primary-bg) !important;
.table {
.row {
height: 80px;
display: flex;
justify-content: center;
.title {
font-weight: 600;
}
}
}
} }

View File

@ -2,5 +2,8 @@
// this is an auto-generated file // this is an auto-generated file
declare const styles: { declare const styles: {
readonly main: string readonly main: string
readonly table: string
readonly row: string
readonly title: string
} }
export default styles export default styles

View File

@ -1,20 +1,88 @@
import React from 'react' import React, { useMemo, useState } from 'react'
import { Button, Container, ButtonVariation, PageBody } from '@harness/uicore' import {
Button,
Container,
ButtonVariation,
PageBody,
Text,
Color,
TableV2,
Layout,
StringSubstitute
} from '@harness/uicore'
import { useHistory } from 'react-router-dom' import { useHistory } from 'react-router-dom'
import { useGet } from 'restful-react'
import type { CellProps, Column } from 'react-table'
import ReactTimeago from 'react-timeago'
import { CodeIcon, makeDiffRefs } from 'utils/GitUtils' import { CodeIcon, makeDiffRefs } from 'utils/GitUtils'
import { useAppContext } from 'AppContext' import { useAppContext } from 'AppContext'
import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata' import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
import { useStrings } from 'framework/strings' import { useStrings } from 'framework/strings'
import { RepositoryPageHeader } from 'components/RepositoryPageHeader/RepositoryPageHeader' import { RepositoryPageHeader } from 'components/RepositoryPageHeader/RepositoryPageHeader'
import { getErrorMessage } from 'utils/Utils' import { getErrorMessage, LIST_FETCHING_PER_PAGE } from 'utils/Utils'
import emptyStateImage from 'images/empty-state.svg' import emptyStateImage from 'images/empty-state.svg'
import type { PullRequestResponse } from 'utils/types'
import { usePageIndex } from 'hooks/usePageIndex'
import { PullRequestsContentHeader } from './PullRequestsContentHeader/PullRequestsContentHeader'
import prOpenImg from './pull-request-open.svg'
import css from './PullRequests.module.scss' import css from './PullRequests.module.scss'
export default function PullRequests() { export default function PullRequests() {
const { getString } = useStrings() const { getString } = useStrings()
const history = useHistory() const history = useHistory()
const { routes } = useAppContext() const { routes } = useAppContext()
const [searchTerm, setSearchTerm] = useState('')
const [pageIndex, setPageIndex] = usePageIndex()
const { repoMetadata, error, loading, refetch } = useGetRepositoryMetadata() const { repoMetadata, error, loading, refetch } = useGetRepositoryMetadata()
const {
data,
error: prError,
loading: prLoading
} = useGet<PullRequestResponse[]>({
path: `/api/v1/repos/${repoMetadata?.path}/+/pullreq`,
queryParams: {
per_page: LIST_FETCHING_PER_PAGE,
page: pageIndex + 1,
sort: 'date',
direction: 'desc',
include_commit: true,
query: searchTerm
},
lazy: !repoMetadata
})
const columns: Column<PullRequestResponse>[] = useMemo(
() => [
{
id: 'title',
width: '100%',
Cell: ({ row }: CellProps<PullRequestResponse>) => {
return (
<Layout.Horizontal spacing="medium" padding={{ left: 'medium' }}>
<img src={prOpenImg} />
<Container padding={{ left: 'small' }}>
<Layout.Vertical spacing="small">
<Text icon="success-tick" color={Color.GREY_800} className={css.title}>
{row.original.title}
</Text>
<Text color={Color.GREY_500}>
<StringSubstitute
str={getString('pr.openBy')}
vars={{
number: <Text inline>{row.original.number}</Text>,
time: <ReactTimeago date={row.original.updated} />,
user: row.original.createdBy
}}
/>
</Text>
</Layout.Vertical>
</Container>
</Layout.Horizontal>
)
}
}
],
[getString]
)
return ( return (
<Container className={css.main}> <Container className={css.main}>
@ -24,11 +92,13 @@ export default function PullRequests() {
dataTooltipId="repositoryPullRequests" dataTooltipId="repositoryPullRequests"
/> />
<PageBody <PageBody
loading={loading} loading={loading || prLoading}
error={getErrorMessage(error)} error={getErrorMessage(error || prError)}
retryOnError={() => refetch()} retryOnError={() => refetch()}
noData={{ noData={{
when: () => repoMetadata !== null, // TODO: Use NoDataCard, this won't render toolbar
// when search returns empty response
when: () => data?.length === 0,
message: getString('pullRequestEmpty'), message: getString('pullRequestEmpty'),
image: emptyStateImage, image: emptyStateImage,
button: ( button: (
@ -47,7 +117,39 @@ export default function PullRequests() {
/> />
) )
}}> }}>
{/* TODO: Render pull request table here - https://www.figma.com/file/PgBvi804VdQNyLS8fD9K0p/SCM?node-id=1220%3A119902&t=D3DaDpST8oO95WSu-0 */} {repoMetadata && (
<Layout.Vertical>
<PullRequestsContentHeader
repoMetadata={repoMetadata}
onPullRequestFilterChanged={_filter => {
setPageIndex(0)
}}
onSearchTermChanged={value => {
setSearchTerm(value)
setPageIndex(0)
}}
/>
{!!data?.length && (
<Container padding="xlarge">
<TableV2<PullRequestResponse>
className={css.table}
hideHeaders
columns={columns}
data={data}
getRowClassName={() => css.row}
onRowClick={row => {
history.push(
routes.toCODEPullRequest({
repoPath: repoMetadata.path as string,
pullRequestId: String(row.number)
})
)
}}
/>
</Container>
)}
</Layout.Vertical>
)}
</PageBody> </PageBody>
</Container> </Container>
) )

View File

@ -0,0 +1,16 @@
.main {
div[class*='TextInput'] {
margin-bottom: 0 !important;
margin-left: 0 !important;
}
> div {
align-items: center;
}
padding-bottom: 0 !important;
}
.branchDropdown {
background-color: var(--white);
}

View File

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

View File

@ -0,0 +1,78 @@
import { useHistory } from 'react-router-dom'
import React, { useMemo, useState } from 'react'
import { Container, Layout, FlexExpander, DropDown, ButtonVariation, TextInput, Button } from '@harness/uicore'
import { useStrings } from 'framework/strings'
import { CodeIcon, GitInfoProps, makeDiffRefs, PullRequestFilterOption } from 'utils/GitUtils'
import { useAppContext } from 'AppContext'
import css from './PullRequestsContentHeader.module.scss'
interface PullRequestsContentHeaderProps extends Pick<GitInfoProps, 'repoMetadata'> {
activePullRequestFilterOption?: string
onPullRequestFilterChanged: (filter: string) => void
onSearchTermChanged: (searchTerm: string) => void
}
export function PullRequestsContentHeader({
onPullRequestFilterChanged,
onSearchTermChanged,
activePullRequestFilterOption = PullRequestFilterOption.ALL,
repoMetadata
}: PullRequestsContentHeaderProps) {
const history = useHistory()
const { routes } = useAppContext()
const { getString } = useStrings()
const [filterOption, setFilterOption] = useState(activePullRequestFilterOption)
const [searchTerm, setSearchTerm] = useState('')
const items = useMemo(
() => [
{ label: getString('open'), value: PullRequestFilterOption.OPEN },
{ label: getString('merged'), value: PullRequestFilterOption.MERGED },
{ label: getString('closed'), value: PullRequestFilterOption.CLOSED },
{ label: getString('rejected'), value: PullRequestFilterOption.REJECTED },
{ label: getString('yours'), value: PullRequestFilterOption.YOURS },
{ label: getString('all'), value: PullRequestFilterOption.ALL }
],
[getString]
)
return (
<Container className={css.main} padding="xlarge">
<Layout.Horizontal spacing="medium">
<DropDown
value={filterOption}
items={items}
onChange={({ value }) => {
setFilterOption(value as string)
onPullRequestFilterChanged(value as string)
}}
popoverClassName={css.branchDropdown}
/>
<FlexExpander />
<TextInput
placeholder={getString('search')}
autoFocus
onFocus={event => event.target.select()}
value={searchTerm}
onInput={event => {
const value = event.currentTarget.value
setSearchTerm(value)
onSearchTermChanged(value)
}}
/>
<Button
variation={ButtonVariation.PRIMARY}
text={getString('newPullRequest')}
icon={CodeIcon.Add}
onClick={() => {
history.push(
routes.toCODECompare({
repoPath: repoMetadata?.path as string,
diffRefs: makeDiffRefs(repoMetadata?.defaultBranch as string, '')
})
)
}}
/>
</Layout.Horizontal>
</Container>
)
}

View File

@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="4.64844" cy="4.65039" r="2.25" stroke="#383946"/>
<path d="M4.64844 6.84475V12.4926V17.1981" stroke="#383946"/>
<circle cx="4.64844" cy="19.1768" r="2.25" stroke="#383946"/>
<path d="M19.3438 3.92676V16.9268" stroke="#383946" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 2"/>
<circle cx="19.3438" cy="19.1768" r="2.25" stroke="#383946"/>
</svg>

After

Width:  |  Height:  |  Size: 475 B

View File

@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="4.51611" cy="4.2373" rx="2.25" ry="2.25" stroke="#4F5162"/>
<path d="M4.51611 6.43166V12.0795V16.785" stroke="#4F5162"/>
<ellipse cx="4.51611" cy="19.2373" rx="2.25" ry="2.25" stroke="#4F5162"/>
<path d="M14.506 7.60547L10.7088 4.7207M10.7088 4.7207L14.506 1.82822M10.7088 4.7207H16.7878" stroke="#4F5162" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.7483 4.71635H18.8538C19.1299 4.71635 19.3538 4.9402 19.3538 5.21635V16.5127" stroke="#4F5162" stroke-linecap="round" stroke-linejoin="round"/>
<ellipse cx="19.4841" cy="19.2373" rx="2.25" ry="2.25" stroke="#4F5162"/>
</svg>

After

Width:  |  Height:  |  Size: 700 B

View File

@ -11,7 +11,7 @@ import { useGetPaginationInfo } from 'hooks/useGetPaginationInfo'
import { useStrings } from 'framework/strings' import { useStrings } from 'framework/strings'
import { RepositoryPageHeader } from 'components/RepositoryPageHeader/RepositoryPageHeader' import { RepositoryPageHeader } from 'components/RepositoryPageHeader/RepositoryPageHeader'
import { BranchTagSelect } from 'components/BranchTagSelect/BranchTagSelect' import { BranchTagSelect } from 'components/BranchTagSelect/BranchTagSelect'
import { CommitsContent } from './RepositoryCommitsContent/CommitsContent/CommitsContent' import { CommitsView } from '../../components/CommitsView/CommitsView'
import css from './RepositoryCommits.module.scss' import css from './RepositoryCommits.module.scss'
export default function RepositoryCommits() { export default function RepositoryCommits() {
@ -71,7 +71,7 @@ export default function RepositoryCommits() {
</Layout.Horizontal> </Layout.Horizontal>
</Container> </Container>
<CommitsContent commits={commits} repoMetadata={repoMetadata} /> <CommitsView commits={commits} repoMetadata={repoMetadata} />
<Container margin={{ left: 'large', right: 'large' }}> <Container margin={{ left: 'large', right: 'large' }}>
<Pagination <Pagination

View File

@ -53,7 +53,6 @@ export default function CreateWehookForm() {
tooltipProps={{ tooltipProps={{
dataTooltipId: 'secret' dataTooltipId: 'secret'
}} }}
inputGroup={{ autoFocus: true }}
/> />
<FormGroup className={css.eventRadioGroup}> <FormGroup className={css.eventRadioGroup}>
<FormInput.RadioGroup <FormInput.RadioGroup

View File

@ -1,25 +1,37 @@
import React from 'react' import React from 'react'
import { Container, PageBody } from '@harness/uicore' import { Container, PageBody } from '@harness/uicore'
import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata' import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
import { useStrings } from 'framework/strings'
import { RepositoryCreateWebhookHeader } from './RepositoryCreateWebhookHeader' import { RepositoryPageHeader } from 'components/RepositoryPageHeader/RepositoryPageHeader'
import { useAppContext } from 'AppContext'
import CreateWebhookForm from './CreateWebhookForm' import CreateWebhookForm from './CreateWebhookForm'
import css from './RepositoryCreateWebhook.module.scss' import css from './RepositoryCreateWebhook.module.scss'
export default function RepositoryCreateWebhook() { export default function RepositoryCreateWebhook() {
const { getString } = useStrings()
const { routes } = useAppContext()
const { repoMetadata, error, loading } = useGetRepositoryMetadata() const { repoMetadata, error, loading } = useGetRepositoryMetadata()
return ( return (
<Container className={css.main}> <Container className={css.main}>
<RepositoryPageHeader
repoMetadata={repoMetadata}
title={getString('webhook')}
dataTooltipId="settingsWebhook"
extraBreadcrumbLinks={
repoMetadata && [
{
label: getString('settings'),
url: routes.toCODESettings({ repoPath: repoMetadata.path as string })
}
]
}
/>
<PageBody loading={loading} error={error}> <PageBody loading={loading} error={error}>
{repoMetadata ? ( {repoMetadata ? (
<> <Container className={css.resourceContent}>
<RepositoryCreateWebhookHeader repoMetadata={repoMetadata} /> <CreateWebhookForm />
<Container className={css.resourceContent}> </Container>
<CreateWebhookForm />
</Container>
</>
) : null} ) : null}
</PageBody> </PageBody>
</Container> </Container>

View File

@ -1,35 +0,0 @@
import React from 'react'
import { Container, Layout, Text, Color, Icon, FontVariation } from '@harness/uicore'
import { Link } from 'react-router-dom'
import { useStrings } from 'framework/strings'
import { useGetSpaceParam } from 'hooks/useGetSpaceParam'
import { useAppContext } from 'AppContext'
import type { GitInfoProps } from 'utils/GitUtils'
import css from './RepositoryCreateWebhook.module.scss'
export function RepositoryCreateWebhookHeader({ repoMetadata }: Pick<GitInfoProps, 'repoMetadata'>) {
const { getString } = useStrings()
const space = useGetSpaceParam()
const { routes } = useAppContext()
return (
<Container className={css.header}>
<Container>
<Layout.Horizontal spacing="small" className={css.breadcrumb}>
<Link to={routes.toCODERepositories({ space })}>{getString('repositories')}</Link>
<Icon name="main-chevron-right" size={10} color={Color.GREY_500} />
<Link to={routes.toCODERepository({ repoPath: repoMetadata.path as string })}>{repoMetadata.uid}</Link>
<Icon name="main-chevron-right" size={10} color={Color.GREY_500} />
<Link to={routes.toCODESettings({ repoPath: repoMetadata.path as string })}>Settings</Link>
</Layout.Horizontal>
<Container padding={{ top: 'medium', bottom: 'medium' }}>
<Text font={{ variation: FontVariation.H4 }}>
<Icon name="main-chevron-right" size={10} color={Color.GREY_500} className={css.addVerticalAlign} />
{getString('webhook')}
</Text>
</Container>
</Container>
</Container>
)
}

View File

@ -48,8 +48,22 @@ export enum GitCommitAction {
MOVE = 'MOVE' MOVE = 'MOVE'
} }
export enum PullRequestState {
OPEN = 'open',
CLOSED = 'closed',
MERGED = 'merged',
REJECTED = 'rejected'
}
export const PullRequestFilterOption = {
...PullRequestState,
YOURS: 'yours',
ALL: 'all'
}
export const CodeIcon = { export const CodeIcon = {
PullRequest: 'git-pull' as IconName, PullRequest: 'git-pull' as IconName,
PullRequestRejected: 'main-close' as IconName,
Add: 'plus' as IconName, Add: 'plus' as IconName,
BranchSmall: 'code-branch-small' as IconName, BranchSmall: 'code-branch-small' as IconName,
Branch: 'code-branch' as IconName, Branch: 'code-branch' as IconName,

View File

@ -21,7 +21,7 @@ export function showToaster(message: string, props?: Partial<IToastProps>): IToa
} }
export const getErrorMessage = (error: Unknown): string => export const getErrorMessage = (error: Unknown): string =>
get(error, 'data.error', get(error, 'data.message', error?.message)) get(error, 'data.error', get(error, 'data.message', get(error, 'message', error)))
export interface SourceCodeEditorProps { export interface SourceCodeEditorProps {
source: string source: string
@ -78,6 +78,15 @@ export function formatDate(timestamp: number | string, dateStyle = 'medium'): st
}).format(new Date(timestamp)) }).format(new Date(timestamp))
} }
/**
* Format a number with current locale.
* @param num number
* @returns Formatted string.
*/
export function formatNumber(num: number | bigint): string {
return new Intl.NumberFormat(LOCALE).format(num)
}
/** /**
* Make any HTML element as a clickable button with keyboard accessibility * Make any HTML element as a clickable button with keyboard accessibility
* support (hit Enter/Space will trigger click event) * support (hit Enter/Space will trigger click event)
@ -89,7 +98,8 @@ export const ButtonRoleProps = {
} }
}, },
tabIndex: 0, tabIndex: 0,
role: 'button' role: 'button',
style: { cursor: 'pointer ' }
} }
const MONACO_SUPPORTED_LANGUAGES = [ const MONACO_SUPPORTED_LANGUAGES = [
@ -173,3 +183,15 @@ export const filenameToLanguage = (name?: string): string | undefined => {
return PLAIN_TEXT return PLAIN_TEXT
} }
export function waitUntil(condition: () => boolean, callback: () => void, maxCount = 100, timeout = 50) {
if (condition()) {
callback()
} else {
if (maxCount) {
setTimeout(() => {
waitUntil(condition, callback, maxCount - 1)
}, timeout)
}
}
}

View File

@ -7802,16 +7802,26 @@ diff-sequences@^29.0.0:
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.0.0.tgz#bae49972ef3933556bcb0800b72e8579d19d9e4f" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.0.0.tgz#bae49972ef3933556bcb0800b72e8579d19d9e4f"
integrity sha512-7Qe/zd1wxSDL4D/X/FPjOMB+ZMDt71W94KYaq05I2l0oQqgXgs7s4ftYYmV38gBSrPz2vcygxfs1xn0FT+rKNA== integrity sha512-7Qe/zd1wxSDL4D/X/FPjOMB+ZMDt71W94KYaq05I2l0oQqgXgs7s4ftYYmV38gBSrPz2vcygxfs1xn0FT+rKNA==
diff2html@3.4.22:
version "3.4.22"
resolved "https://registry.yarnpkg.com/diff2html/-/diff2html-3.4.22.tgz#ae9d0f1949a5858141c737dd0b3a6d7fbcee85a2"
integrity sha512-n7b0cfjqG+NLAmxdlSRMXIGoeoK+OhCNxd/6InjjMxy3v5Gk1L5Ms5r4jziFiRBoNaB6L8lLyZ8KQ8Kv+DOgnw==
dependencies:
diff "5.1.0"
hogan.js "3.0.2"
optionalDependencies:
highlight.js "11.6.0"
diff@5.1.0, diff@^5.0.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40"
integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==
diff@^4.0.1: diff@^4.0.1:
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
diff@^5.0.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40"
integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==
diffie-hellman@^5.0.0: diffie-hellman@^5.0.0:
version "5.0.3" version "5.0.3"
resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"
@ -10162,7 +10172,7 @@ highcharts@9.1.0:
resolved "https://registry.yarnpkg.com/highcharts/-/highcharts-9.1.0.tgz#2cdb38e2e03530b4fde022bb05fbce5b34651e39" resolved "https://registry.yarnpkg.com/highcharts/-/highcharts-9.1.0.tgz#2cdb38e2e03530b4fde022bb05fbce5b34651e39"
integrity sha512-K7HUuKhEylZ1pMdzGR35kPgUmpp0MDNpaWhEMkGiC5Jfzg/endtTLHJN2lsFqEO+xoN7AykBK98XaJPEpsrLyA== integrity sha512-K7HUuKhEylZ1pMdzGR35kPgUmpp0MDNpaWhEMkGiC5Jfzg/endtTLHJN2lsFqEO+xoN7AykBK98XaJPEpsrLyA==
highlight.js@~11.6.0: highlight.js@11.6.0, highlight.js@~11.6.0:
version "11.6.0" version "11.6.0"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.6.0.tgz#a50e9da05763f1bb0c1322c8f4f755242cff3f5a" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.6.0.tgz#a50e9da05763f1bb0c1322c8f4f755242cff3f5a"
integrity sha512-ig1eqDzJaB0pqEvlPVIpSSyMaO92bH1N2rJpLMN/nX396wTpDA4Eq0uK+7I/2XG17pFaaKE0kjV/XPeGt7Evjw== integrity sha512-ig1eqDzJaB0pqEvlPVIpSSyMaO92bH1N2rJpLMN/nX396wTpDA4Eq0uK+7I/2XG17pFaaKE0kjV/XPeGt7Evjw==
@ -10188,6 +10198,14 @@ hmac-drbg@^1.0.1:
minimalistic-assert "^1.0.0" minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1" minimalistic-crypto-utils "^1.0.1"
hogan.js@3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/hogan.js/-/hogan.js-3.0.2.tgz#4cd9e1abd4294146e7679e41d7898732b02c7bfd"
integrity sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==
dependencies:
mkdirp "0.3.0"
nopt "1.0.10"
hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2:
version "3.3.2" version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
@ -13771,6 +13789,11 @@ mixin-deep@^1.2.0:
for-in "^1.0.2" for-in "^1.0.2"
is-extendable "^1.0.1" is-extendable "^1.0.1"
mkdirp@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e"
integrity sha512-OHsdUcVAQ6pOtg5JYWpCBo9W/GySVuwvP9hueRMW7UqshC0tbfzLv8wjySTPm3tfUZ/21CE9E1pJagOA91Pxew==
mkdirp@0.5.x, mkdirp@^0.5.1, mkdirp@^0.5.3: mkdirp@0.5.x, mkdirp@^0.5.1, mkdirp@^0.5.3:
version "0.5.6" version "0.5.6"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
@ -14123,7 +14146,7 @@ noms@0.0.0:
inherits "^2.0.1" inherits "^2.0.1"
readable-stream "~1.0.31" readable-stream "~1.0.31"
nopt@~1.0.10: nopt@1.0.10, nopt@~1.0.10:
version "1.0.10" version "1.0.10"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee"
integrity sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg== integrity sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==
@ -15873,6 +15896,11 @@ react-inspector@^5.1.0:
is-dom "^1.0.0" is-dom "^1.0.0"
prop-types "^15.0.0" prop-types "^15.0.0"
react-intersection-observer@^9.4.1:
version "9.4.1"
resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-9.4.1.tgz#4ccb21e16acd0b9cf5b28d275af7055bef878f6b"
integrity sha512-IXpIsPe6BleFOEHKzKh5UjwRUaz/JYS0lT/HPsupWEQou2hDqjhLMStc5zyE3eQVT4Fk3FufM8Fw33qW1uyeiw==
react-is@17.0.2, react-is@^17.0.1, react-is@^17.0.2: react-is@17.0.2, react-is@^17.0.1, react-is@^17.0.2:
version "17.0.2" version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"