RevisionsPopover: Refactor and support speculative results (#23973)

This commit is contained in:
Tom Ross 2021-08-17 15:30:46 +01:00 committed by GitHub
parent 45ce316b7e
commit 364145452b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 5086 additions and 4089 deletions

View File

@ -2,6 +2,7 @@ import React from 'react'
import { MemoryRouter, MemoryRouterProps } from 'react-router'
import { ThemeProps } from '@sourcegraph/shared/src/theme'
import { MockedStoryProvider, MockedStoryProviderProps } from '@sourcegraph/storybook/src/apollo/MockedStoryProvider'
import { usePrependStyles } from '@sourcegraph/storybook/src/hooks/usePrependStyles'
import { useTheme } from '@sourcegraph/storybook/src/hooks/useTheme'
@ -9,7 +10,7 @@ import brandedStyles from '../global-styles/index.scss'
import { Tooltip } from './tooltip/Tooltip'
export interface BrandedProps extends MemoryRouterProps {
export interface BrandedProps extends MemoryRouterProps, Pick<MockedStoryProviderProps, 'mocks' | 'useStrictMocking'> {
children: React.FunctionComponent<ThemeProps>
styles?: string
}
@ -21,15 +22,19 @@ export interface BrandedProps extends MemoryRouterProps {
export const BrandedStory: React.FunctionComponent<BrandedProps> = ({
children: Children,
styles = brandedStyles,
mocks,
useStrictMocking,
...memoryRouterProps
}) => {
const isLightTheme = useTheme()
usePrependStyles('branded-story-styles', styles)
return (
<MemoryRouter {...memoryRouterProps}>
<Tooltip />
<Children isLightTheme={isLightTheme} />
</MemoryRouter>
<MockedStoryProvider mocks={mocks} useStrictMocking={useStrictMocking}>
<MemoryRouter {...memoryRouterProps}>
<Tooltip />
<Children isLightTheme={isLightTheme} />
</MemoryRouter>
</MockedStoryProvider>
)
}

View File

@ -4,8 +4,21 @@ import { TypedTypePolicies } from '../graphql-operations'
// Defines how the Apollo cache interacts with our GraphQL schema.
// See https://www.apollographql.com/docs/react/caching/cache-configuration/#typepolicy-fields
const typePolicies: TypedTypePolicies = {}
const typePolicies: TypedTypePolicies = {
Query: {
fields: {
node: {
// Node is a top-level interface field used to easily fetch from different parts of the schema through the relevant `id`.
// We always want to merge responses from this field as it will be used through very different queries.
merge: true,
},
},
},
}
export const cache = new InMemoryCache({
typePolicies,
})
export const generateCache = (): InMemoryCache =>
new InMemoryCache({
typePolicies,
})
export const cache = generateCache()

View File

@ -1,7 +0,0 @@
import { act } from '@testing-library/react'
/*
* Wait one tick to load the next response from Apollo
* https://www.apollographql.com/docs/react/development-testing/testing/#testing-the-success-state
*/
export const waitForNextApolloResponse = (): Promise<void> => act(() => new Promise(resolve => setTimeout(resolve, 0)))

View File

@ -0,0 +1,34 @@
import { MockedProvider, MockedProviderProps } from '@apollo/client/testing'
import { act } from '@testing-library/react'
import React, { useMemo } from 'react'
import { generateCache } from '../graphql/cache'
/*
* Wait one tick to load the next response from Apollo
* https://www.apollographql.com/docs/react/development-testing/testing/#testing-the-success-state
*/
export const waitForNextApolloResponse = (): Promise<void> => act(() => new Promise(resolve => setTimeout(resolve, 0)))
export const MockedTestProvider: React.FunctionComponent<MockedProviderProps> = ({ children, ...props }) => {
/**
* Generate a fresh cache for each instance of MockedTestProvider.
* Important to ensure tests don't share cached data.
*/
const cache = useMemo(() => generateCache(), [])
return (
<MockedProvider
cache={cache}
defaultOptions={{
mutate: {
// Fix errors being thrown globally https://github.com/apollographql/apollo-client/issues/7167
errorPolicy: 'all',
},
}}
{...props}
>
{children}
</MockedProvider>
)
}

View File

@ -3,6 +3,8 @@ import { MockedProvider, MockedProviderProps, MockedResponse, MockLink } from '@
import { getOperationName } from '@apollo/client/utilities'
import React from 'react'
import { cache } from '@sourcegraph/shared/src/graphql/cache'
/**
* Intercept each mocked Apollo request and ensure that any request variables match the specified mock.
* This effectively means we are mocking agains the operationName of the query being fired.
@ -18,20 +20,32 @@ const forceMockVariablesLink = (mocks: readonly MockedResponse[]): ApolloLink =>
return forward(operation)
})
export interface MockedStoryProviderProps extends MockedProviderProps {
/**
* Set this to `true` to preserve the default behavior of MockedProvider.
* Requests will require that both the `operationName` **and** `variables` match the mock to be resolved.
*/
useStrictMocking?: boolean
}
/**
* A wrapper around MockedProvider with a custom ApolloLink to ensure flexible request mocking.
*
* MockedProvider does not support dynamic variable matching for mocks.
* This wrapper **only** mocks against the operation name, the specific provided variables are not used to match against a mock.
*/
export const MockedStoryProvider: React.FunctionComponent<MockedProviderProps> = ({
export const MockedStoryProvider: React.FunctionComponent<MockedStoryProviderProps> = ({
children,
mocks = [],
useStrictMocking,
...props
}) => (
<MockedProvider
cache={cache}
mocks={mocks}
link={ApolloLink.from([forceMockVariablesLink(mocks), new MockLink(mocks)])}
link={ApolloLink.from(
useStrictMocking ? [new MockLink(mocks)] : [forceMockVariablesLink(mocks), new MockLink(mocks)]
)}
{...props}
>
{children}

View File

@ -34,6 +34,9 @@
}
&__nodes {
&:empty {
display: none;
}
.theme-redesign & {
border-top: solid 1px var(--border-color-2);
padding-top: 0.25rem;

View File

@ -73,7 +73,7 @@ export interface ConnectionNodesDisplayProps {
noSummaryIfAllNodesVisible?: boolean
/** The component displayed when the list of nodes is empty. */
emptyElement?: JSX.Element
emptyElement?: JSX.Element | null
/** The component displayed when all nodes have been fetched. */
totalCountSummaryComponent?: React.ComponentType<{ totalCount: number }>
@ -133,11 +133,10 @@ export const ConnectionNodes = <C extends Connection<N>, N, NP = {}, HP = {}>({
}: ConnectionNodesProps<C, N, NP, HP>): JSX.Element => {
const nextPage = hasNextPage(connection)
const totalCount = getTotalCount(connection, first)
const summary = (
<ConnectionSummary
first={first}
noSummaryIfAllNodesVisible={noSummaryIfAllNodesVisible}
totalCount={totalCount}
totalCountSummaryComponent={totalCountSummaryComponent}
noun={noun}
pluralNoun={pluralNoun}

View File

@ -1,4 +1,3 @@
import classNames from 'classnames'
import * as H from 'history'
import { uniq } from 'lodash'
import * as React from 'react'
@ -25,7 +24,7 @@ import { ConnectionNodes, ConnectionNodesState, ConnectionNodesDisplayProps, Con
import { Connection, ConnectionQueryArguments } from './ConnectionType'
import { QUERY_KEY } from './constants'
import { FilteredConnectionFilter, FilteredConnectionFilterValue } from './FilterControl'
import { ConnectionError, ConnectionLoading, ConnectionForm } from './ui'
import { ConnectionError, ConnectionLoading, ConnectionForm, ConnectionContainer } from './ui'
import type { ConnectionFormProps } from './ui/ConnectionForm'
import { getFilterFromURL, getUrlQuery, parseQueryInt } from './utils'
@ -469,15 +468,8 @@ export class FilteredConnection<
// this.state.connectionOrError.nodes.length > 0 &&
// this.props.hideControlsWhenEmpty
const compactnessClass = `filtered-connection--${this.props.compact ? 'compact' : 'noncompact'}`
return (
<div
className={classNames(
'filtered-connection test-filtered-connection',
compactnessClass,
this.props.className
)}
>
<ConnectionContainer compact={this.props.compact} className={this.props.className}>
{
/* shouldShowControls && */ (!this.props.hideSearch || this.props.filters) && (
<ConnectionForm
@ -522,7 +514,7 @@ export class FilteredConnection<
/>
)}
{this.state.loading && <ConnectionLoading className={this.props.loaderClassName} />}
</div>
</ConnectionContainer>
)
}

View File

@ -1,9 +1,9 @@
import { MockedProvider, MockedResponse } from '@apollo/client/testing'
import { MockedResponse } from '@apollo/client/testing'
import { fireEvent } from '@testing-library/react'
import React from 'react'
import { dataOrThrowErrors, getDocumentNode, gql } from '@sourcegraph/shared/src/graphql/graphql'
import { waitForNextApolloResponse } from '@sourcegraph/shared/src/testing/apollo'
import { MockedTestProvider, waitForNextApolloResponse } from '@sourcegraph/shared/src/testing/apollo'
import { renderWithRouter, RenderWithRouterResult } from '@sourcegraph/shared/src/testing/render-with-router'
import {
@ -148,9 +148,9 @@ describe('useConnection', () => {
const renderWithMocks = async (mocks: MockedResponse<TestConnectionQueryResult>[], route = '/') => {
const renderResult = renderWithRouter(
<MockedProvider mocks={mocks}>
<MockedTestProvider mocks={mocks}>
<TestComponent />
</MockedProvider>,
</MockedTestProvider>,
{ route }
)

View File

@ -1,5 +1,4 @@
import { QueryResult } from '@apollo/client'
import { GraphQLError } from 'graphql'
import { ApolloError, QueryResult } from '@apollo/client'
import { useMemo, useRef } from 'react'
import { GraphQLResult, useQuery } from '@sourcegraph/shared/src/graphql/graphql'
@ -10,9 +9,9 @@ import { Connection, ConnectionQueryArguments } from '../ConnectionType'
import { useConnectionUrl } from './useConnectionUrl'
interface UseConnectionResult<TData> {
export interface UseConnectionResult<TData> {
connection?: Connection<TData>
errors?: readonly GraphQLError[]
error?: ApolloError
fetchMore: () => void
loading: boolean
hasNextPage: boolean
@ -93,6 +92,7 @@ export const useConnection = <TResult, TVariables, TData>({
...variables,
...initialControls,
},
notifyOnNetworkStatusChange: true, // Ensures loading state is updated on `fetchMore`
})
/**
@ -147,7 +147,7 @@ export const useConnection = <TResult, TVariables, TData>({
return {
connection,
loading,
errors: error?.graphQLErrors,
error,
fetchMore: fetchMoreData,
hasNextPage: connection ? hasNextPage(connection) : false,
}

View File

@ -1,7 +1,9 @@
import classNames from 'classnames'
import React, { useCallback } from 'react'
import React, { useCallback, useRef } from 'react'
import { useMergeRefs } from 'use-callback-ref'
import { Form } from '@sourcegraph/branded/src/components/Form'
import { useAutoFocus } from '@sourcegraph/wildcard'
import { FilterControl, FilteredConnectionFilter, FilteredConnectionFilterValue } from '../FilterControl'
@ -59,11 +61,15 @@ export const ConnectionForm = React.forwardRef<HTMLInputElement, ConnectionFormP
},
reference
) => {
const localReference = useRef<HTMLInputElement>(null)
const mergedReference = useMergeRefs([localReference, reference])
const handleSubmit = useCallback<React.FormEventHandler<HTMLFormElement>>(event => {
// Do nothing. The <input onChange> handler will pick up any changes shortly.
event.preventDefault()
}, [])
useAutoFocus({ autoFocus, reference: localReference })
return (
<Form
className="w-100 d-inline-flex justify-content-between flex-row filtered-connection__form"
@ -86,7 +92,7 @@ export const ConnectionForm = React.forwardRef<HTMLInputElement, ConnectionFormP
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
ref={reference}
ref={mergedReference}
spellCheck={false}
/>
)}

View File

@ -2,7 +2,7 @@ import * as React from 'react'
import { pluralize } from '@sourcegraph/shared/src/util/strings'
import { ConnectionNodesState, ConnectionProps } from '../ConnectionNodes'
import { ConnectionNodesState, ConnectionProps, getTotalCount } from '../ConnectionNodes'
import { Connection } from '../ConnectionType'
interface ConnectionNodesSummaryProps<C extends Connection<N>, N, NP = {}, HP = {}>
@ -14,13 +14,12 @@ interface ConnectionNodesSummaryProps<C extends Connection<N>, N, NP = {}, HP =
| 'pluralNoun'
| 'connectionQuery'
| 'emptyElement'
| 'first'
> {
/** The fetched connection data or an error (if an error occurred). */
connection: C
hasNextPage: boolean
totalCount: number | null
}
/**
@ -31,12 +30,12 @@ export const ConnectionSummary = <C extends Connection<N>, N, NP = {}, HP = {}>(
noSummaryIfAllNodesVisible,
connection,
hasNextPage,
totalCount,
totalCountSummaryComponent: TotalCountSummaryComponent,
noun,
pluralNoun,
connectionQuery,
emptyElement,
first,
}: ConnectionNodesSummaryProps<C, N, NP, HP>): JSX.Element | null => {
const shouldShowSummary = !noSummaryIfAllNodesVisible || connection.nodes.length === 0 || hasNextPage
@ -44,6 +43,9 @@ export const ConnectionSummary = <C extends Connection<N>, N, NP = {}, HP = {}>(
return null
}
// We cannot always rely on `connection.totalCount` to be returned, fallback to `connection.nodes.length` if possible.
const totalCount = getTotalCount(connection, first)
if (totalCount !== null && totalCount > 0 && TotalCountSummaryComponent) {
return <TotalCountSummaryComponent totalCount={totalCount} />
}

View File

@ -1,11 +1,10 @@
import { MockedResponse } from '@apollo/client/testing'
import React, { useMemo } from 'react'
import { MemoryRouter, MemoryRouterProps, RouteComponentProps, withRouter } from 'react-router'
import { Tooltip } from '@sourcegraph/branded/src/components/tooltip/Tooltip'
import { NOOP_TELEMETRY_SERVICE, TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { ThemeProps } from '@sourcegraph/shared/src/theme'
import { MockedStoryProvider } from '@sourcegraph/storybook/src/apollo/MockedStoryProvider'
import { MockedStoryProvider, MockedStoryProviderProps } from '@sourcegraph/storybook/src/apollo/MockedStoryProvider'
import { usePrependStyles } from '@sourcegraph/storybook/src/hooks/usePrependStyles'
import { useTheme } from '@sourcegraph/storybook/src/hooks/useTheme'
@ -13,12 +12,11 @@ import webStyles from '../SourcegraphWebApp.scss'
import { BreadcrumbSetters, BreadcrumbsProps, useBreadcrumbs } from './Breadcrumbs'
export interface WebStoryProps extends MemoryRouterProps {
export interface WebStoryProps extends MemoryRouterProps, Pick<MockedStoryProviderProps, 'mocks' | 'useStrictMocking'> {
children: React.FunctionComponent<
ThemeProps & BreadcrumbSetters & BreadcrumbsProps & TelemetryProps & RouteComponentProps<any>
>
additionalWebStyles?: string
mocks?: readonly MockedResponse[]
}
/**
@ -29,6 +27,7 @@ export const WebStory: React.FunctionComponent<WebStoryProps> = ({
children,
additionalWebStyles,
mocks,
useStrictMocking,
...memoryRouterProps
}) => {
const isLightTheme = useTheme()
@ -39,7 +38,7 @@ export const WebStory: React.FunctionComponent<WebStoryProps> = ({
usePrependStyles('web-styles', webStyles)
return (
<MockedStoryProvider mocks={mocks}>
<MockedStoryProvider mocks={mocks} useStrictMocking={useStrictMocking}>
<MemoryRouter {...memoryRouterProps}>
<Tooltip />
<Children

View File

@ -1,8 +1,9 @@
import classnames from 'classnames'
import React, { useRef, forwardRef, InputHTMLAttributes, ReactNode, useEffect } from 'react'
import React, { useRef, forwardRef, InputHTMLAttributes, ReactNode } from 'react'
import { useMergeRefs } from 'use-callback-ref'
import { LoaderInput } from '@sourcegraph/branded/src/components/LoaderInput'
import { useAutoFocus } from '@sourcegraph/wildcard'
import styles from './FormInput.module.scss'
import { ForwardReferenceComponent } from './types'
@ -53,19 +54,7 @@ const FormInput = forwardRef((props, reference) => {
const localReference = useRef<HTMLInputElement>(null)
const mergedReference = useMergeRefs([localReference, reference])
useEffect(() => {
if (autoFocus) {
// In some cases if form input has been rendered within reach/portal element
// react autoFocus set focus too early and in this case we have to
// call focus explicitly in the next tick to be sure that focus will be
// on input element. See reach/portal implementation and notice async way to
// render children in react portal component.
// https://github.com/reach/reach-ui/blob/0ae833201cf842fc00859612cfc6c30a593d593d/packages/portal/src/index.tsx#L45
requestAnimationFrame(() => {
localReference.current?.focus()
})
}
}, [autoFocus])
useAutoFocus({ autoFocus, reference: localReference })
return (
<label className={classnames('w-100', className)}>

View File

@ -1,10 +1,10 @@
import { MockedProvider, MockedResponse } from '@apollo/client/testing'
import { MockedResponse } from '@apollo/client/testing'
import { render, RenderResult, fireEvent } from '@testing-library/react'
import { GraphQLError } from 'graphql'
import React from 'react'
import { getDocumentNode } from '@sourcegraph/shared/src/graphql/graphql'
import { waitForNextApolloResponse } from '@sourcegraph/shared/src/testing/apollo'
import { MockedTestProvider, waitForNextApolloResponse } from '@sourcegraph/shared/src/testing/apollo'
import { SubmitHappinessFeedbackVariables, SubmitHappinessFeedbackResult } from '../../graphql-operations'
import { routes } from '../../routes'
@ -33,9 +33,9 @@ describe('FeedbackPrompt', () => {
describe('layout', () => {
beforeEach(() => {
queries = render(
<MockedProvider>
<MockedTestProvider>
<FeedbackPrompt routes={routes} />
</MockedProvider>
</MockedTestProvider>
)
})
@ -105,9 +105,9 @@ describe('FeedbackPrompt', () => {
beforeEach(async () => {
queries = render(
<MockedProvider mocks={[successMock]}>
<MockedTestProvider mocks={[successMock]}>
<FeedbackPrompt routes={routes} />
</MockedProvider>
</MockedTestProvider>
)
await submitFeedback()
@ -128,17 +128,9 @@ describe('FeedbackPrompt', () => {
}
beforeEach(async () => {
queries = render(
<MockedProvider
mocks={[errorMock]}
defaultOptions={{
mutate: {
// Fix errors being thrown globally https://github.com/apollographql/apollo-client/issues/7167
errorPolicy: 'all',
},
}}
>
<MockedTestProvider mocks={[errorMock]}>
<FeedbackPrompt routes={routes} />
</MockedProvider>
</MockedTestProvider>
)
await submitFeedback()

View File

@ -20,7 +20,7 @@ import {
Scalars,
} from '../graphql-operations'
interface GitReferenceNodeProps {
export interface GitReferenceNodeProps {
node: GitRefFields
/** Link URL; if undefined, node.url is used. */
@ -32,6 +32,10 @@ interface GitReferenceNodeProps {
children?: React.ReactNode
className?: string
icon?: React.ComponentType<{ className?: string }>
onClick?: React.MouseEventHandler<HTMLAnchorElement>
}
export const GitReferenceNode: React.FunctionComponent<GitReferenceNodeProps> = ({
@ -40,6 +44,8 @@ export const GitReferenceNode: React.FunctionComponent<GitReferenceNodeProps> =
ancestorIsLink,
children,
className,
onClick,
icon: Icon,
}) => {
const mostRecentSig =
node.target.commit &&
@ -54,8 +60,10 @@ export const GitReferenceNode: React.FunctionComponent<GitReferenceNodeProps> =
key={node.id}
className={classNames('git-ref-node list-group-item', className)}
to={!ancestorIsLink ? url : undefined}
onClick={onClick}
>
<span className="d-flex align-items-center">
{Icon && <Icon className="icon-inline mr-1" />}
<code className="badge">{node.displayName}</code>
{mostRecentSig && (
<small className="pl-2">
@ -98,6 +106,7 @@ export const gitReferenceFragments = gql`
}
fragment SignatureFieldsForReferences on Signature {
__typename
person {
displayName
user {
@ -108,6 +117,33 @@ export const gitReferenceFragments = gql`
}
`
export const REPOSITORY_GIT_REFS = gql`
query RepositoryGitRefs($repo: ID!, $first: Int, $query: String, $type: GitRefType!, $withBehindAhead: Boolean!) {
node(id: $repo) {
__typename
... on Repository {
gitRefs(first: $first, query: $query, type: $type, orderBy: AUTHORED_OR_COMMITTED_AT) {
__typename
...GitRefConnectionFields
}
}
}
}
fragment GitRefConnectionFields on GitRefConnection {
nodes {
__typename
...GitRefFields
}
totalCount
pageInfo {
hasNextPage
}
}
${gitReferenceFragments}
`
export const queryGitReferences = memoizeObservable(
(args: {
repo: Scalars['ID']
@ -116,47 +152,16 @@ export const queryGitReferences = memoizeObservable(
type: GitRefType
withBehindAhead?: boolean
}): Observable<GitRefConnectionFields> =>
requestGraphQL<RepositoryGitRefsResult, RepositoryGitRefsVariables>(
gql`
query RepositoryGitRefs(
$repo: ID!
$first: Int
$query: String
$type: GitRefType!
$withBehindAhead: Boolean!
) {
node(id: $repo) {
... on Repository {
gitRefs(first: $first, query: $query, type: $type, orderBy: AUTHORED_OR_COMMITTED_AT) {
...GitRefConnectionFields
}
}
}
}
fragment GitRefConnectionFields on GitRefConnection {
nodes {
...GitRefFields
}
totalCount
pageInfo {
hasNextPage
}
}
${gitReferenceFragments}
`,
{
query: args.query ?? null,
first: args.first ?? null,
repo: args.repo,
type: args.type,
withBehindAhead:
args.withBehindAhead !== undefined ? args.withBehindAhead : args.type === GitRefType.GIT_BRANCH,
}
).pipe(
requestGraphQL<RepositoryGitRefsResult, RepositoryGitRefsVariables>(REPOSITORY_GIT_REFS, {
query: args.query ?? null,
first: args.first ?? null,
repo: args.repo,
type: args.type,
withBehindAhead:
args.withBehindAhead !== undefined ? args.withBehindAhead : args.type === GitRefType.GIT_BRANCH,
}).pipe(
map(({ data, errors }) => {
if (!data || !data.node || !data.node.gitRefs) {
if (!data || !data.node || data.node.__typename !== 'Repository' || !data.node.gitRefs) {
throw createAggregateError(errors)
}
return data.node.gitRefs

View File

@ -4,7 +4,7 @@
@import './GitReference';
@import '../marketing/Toast';
@import './RepoRevisionSidebar';
@import './RevisionsPopover';
@import './RevisionsPopover/RevisionsPopover';
@import './docs/RepositoryDocumentationPage';
@import './commits/RepositoryCommitsPage';

View File

@ -125,8 +125,7 @@ interface RepoRevisionContainerProps
isMacPlatform: boolean
}
interface RepoRevisionBreadcrumbProps
extends Pick<RepoRevisionContainerProps, 'repo' | 'revision' | 'history' | 'location'> {
interface RepoRevisionBreadcrumbProps extends Pick<RepoRevisionContainerProps, 'repo' | 'revision'> {
resolvedRevisionOrError: ResolvedRevision
}
@ -134,8 +133,6 @@ const RepoRevisionContainerBreadcrumb: React.FunctionComponent<RepoRevisionBread
revision,
resolvedRevisionOrError,
repo,
history,
location,
}) => (
<button
type="button"
@ -154,14 +151,11 @@ const RepoRevisionContainerBreadcrumb: React.FunctionComponent<RepoRevisionBread
repo={repo}
resolvedRevisionOrError={resolvedRevisionOrError}
revision={revision}
history={history}
location={location}
/>
</button>
)
interface RepoRevisionContainerPopoverProps
extends Pick<RepoRevisionContainerProps, 'repo' | 'revision' | 'history' | 'location'> {
interface RepoRevisionContainerPopoverProps extends Pick<RepoRevisionContainerProps, 'repo' | 'revision'> {
resolvedRevisionOrError: ResolvedRevision
}
@ -169,8 +163,6 @@ const RepoRevisionContainerPopover: React.FunctionComponent<RepoRevisionContaine
repo,
resolvedRevisionOrError,
revision,
history,
location,
}) => {
const [popoverOpen, setPopoverOpen] = useState(false)
const togglePopover = useCallback(() => setPopoverOpen(previous => !previous), [])
@ -192,9 +184,8 @@ const RepoRevisionContainerPopover: React.FunctionComponent<RepoRevisionContaine
defaultBranch={resolvedRevisionOrError.defaultBranch}
currentRev={revision}
currentCommitID={resolvedRevisionOrError.commitID}
history={history}
location={location}
togglePopover={togglePopover}
onSelect={togglePopover}
/>
</Popover>
)
@ -222,12 +213,10 @@ export const RepoRevisionContainer: React.FunctionComponent<RepoRevisionContaine
resolvedRevisionOrError={props.resolvedRevisionOrError}
revision={props.revision}
repo={props.repo}
history={props.history}
location={props.location}
/>
),
}
}, [props.resolvedRevisionOrError, props.revision, props.repo, props.history, props.location])
}, [props.resolvedRevisionOrError, props.revision, props.repo])
)
if (!props.resolvedRevisionOrError) {

View File

@ -1,306 +0,0 @@
import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@reach/tabs'
import classNames from 'classnames'
import * as H from 'history'
import CloseIcon from 'mdi-react/CloseIcon'
import React, { useCallback, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { CircleChevronLeftIcon } from '@sourcegraph/shared/src/components/icons'
import { GitRefType, Scalars } from '@sourcegraph/shared/src/graphql-operations'
import { gql, dataOrThrowErrors } from '@sourcegraph/shared/src/graphql/graphql'
import { memoizeObservable } from '@sourcegraph/shared/src/util/memoizeObservable'
import { RevisionSpec } from '@sourcegraph/shared/src/util/url'
import { useLocalStorage } from '@sourcegraph/shared/src/util/useLocalStorage'
import { requestGraphQL } from '../backend/graphql'
import { FilteredConnection, FilteredConnectionQueryArguments } from '../components/FilteredConnection'
import {
GitCommitAncestorFields,
GitCommitAncestorsConnectionFields,
GitRefConnectionFields,
GitRefFields,
RepositoryGitCommitResult,
RepositoryGitCommitVariables,
} from '../graphql-operations'
import { eventLogger } from '../tracking/eventLogger'
import { replaceRevisionInURL } from '../util/url'
import { GitReferenceNode, queryGitReferences } from './GitReference'
const fetchRepositoryCommits = memoizeObservable(
(
args: RevisionSpec & { repo: Scalars['ID']; first?: number; query?: string }
): Observable<GitCommitAncestorsConnectionFields> =>
requestGraphQL<RepositoryGitCommitResult, RepositoryGitCommitVariables>(
gql`
query RepositoryGitCommit($repo: ID!, $first: Int, $revision: String!, $query: String) {
node(id: $repo) {
__typename
... on Repository {
commit(rev: $revision) {
ancestors(first: $first, query: $query) {
...GitCommitAncestorsConnectionFields
}
}
}
}
}
fragment GitCommitAncestorsConnectionFields on GitCommitConnection {
nodes {
...GitCommitAncestorFields
}
pageInfo {
hasNextPage
}
}
fragment GitCommitAncestorFields on GitCommit {
id
oid
abbreviatedOID
author {
person {
name
avatarURL
}
date
}
subject
}
`,
{
first: args.first ?? null,
query: args.query ?? null,
repo: args.repo,
revision: args.revision,
}
).pipe(
map(dataOrThrowErrors),
map(({ node }) => {
if (!node) {
throw new Error(`Repository ${args.repo} not found`)
}
if (node.__typename !== 'Repository') {
throw new Error(`Node is a ${node.__typename}, not a Repository`)
}
if (!node.commit?.ancestors) {
throw new Error(`Cannot load ancestors for repository ${args.repo}`)
}
return node.commit.ancestors
})
),
args => JSON.stringify(args)
)
interface GitReferencePopoverNodeProps {
node: GitRefFields
defaultBranch: string
currentRevision: string | undefined
location: H.Location
}
const GitReferencePopoverNode: React.FunctionComponent<GitReferencePopoverNodeProps> = ({
node,
defaultBranch,
currentRevision,
location,
}) => {
let isCurrent: boolean
if (currentRevision) {
isCurrent = node.name === currentRevision || node.abbrevName === currentRevision
} else {
isCurrent = node.name === `refs/heads/${defaultBranch}`
}
return (
<GitReferenceNode
node={node}
url={replaceRevisionInURL(location.pathname + location.search + location.hash, node.abbrevName)}
ancestorIsLink={false}
className={classNames(
'connection-popover__node-link',
isCurrent && 'connection-popover__node-link--active'
)}
>
{isCurrent && (
<CircleChevronLeftIcon
className="icon-inline connection-popover__node-link-icon"
data-tooltip="Current"
/>
)}
</GitReferenceNode>
)
}
interface GitCommitNodeProps {
node: GitCommitAncestorFields
currentCommitID: string | undefined
location: H.Location
}
const GitCommitNode: React.FunctionComponent<GitCommitNodeProps> = ({ node, currentCommitID, location }) => {
const isCurrent = currentCommitID === node.oid
return (
<li key={node.oid} className="connection-popover__node revisions-popover-git-commit-node">
<Link
to={replaceRevisionInURL(location.pathname + location.search + location.hash, node.oid)}
className={classNames(
'connection-popover__node-link',
isCurrent && 'connection-popover__node-link--active',
'revisions-popover-git-commit-node__link'
)}
>
<code className="badge" title={node.oid}>
{node.abbreviatedOID}
</code>
<small className="revisions-popover-git-commit-node__message">{node.subject.slice(0, 200)}</small>
{isCurrent && (
<CircleChevronLeftIcon
className="icon-inline connection-popover__node-link-icon"
data-tooltip="Current commit"
/>
)}
</Link>
</li>
)
}
interface Props {
repo: Scalars['ID']
repoName: string
defaultBranch: string
/** The current revision, or undefined for the default branch. */
currentRev: string | undefined
currentCommitID?: string
history: H.History
location: H.Location
/* Callback to dismiss the parent popover wrapper */
togglePopover: () => void
}
type RevisionsPopoverTabID = 'branches' | 'tags' | 'commits'
interface RevisionsPopoverTab {
id: RevisionsPopoverTabID
label: string
noun: string
pluralNoun: string
type?: GitRefType
}
const LAST_TAB_STORAGE_KEY = 'RevisionsPopover.lastTab'
const TABS: RevisionsPopoverTab[] = [
{ id: 'branches', label: 'Branches', noun: 'branch', pluralNoun: 'branches', type: GitRefType.GIT_BRANCH },
{ id: 'tags', label: 'Tags', noun: 'tag', pluralNoun: 'tags', type: GitRefType.GIT_TAG },
{ id: 'commits', label: 'Commits', noun: 'commit', pluralNoun: 'commits' },
]
/**
* A popover that displays a searchable list of revisions (grouped by type) for
* the current repository.
*/
export const RevisionsPopover: React.FunctionComponent<Props> = props => {
useEffect(() => {
eventLogger.logViewEvent('RevisionsPopover')
}, [])
const [tabIndex, setTabIndex] = useLocalStorage(LAST_TAB_STORAGE_KEY, 0)
const handleTabsChange = useCallback((index: number) => setTabIndex(index), [setTabIndex])
const queryGitBranches = (args: FilteredConnectionQueryArguments): Observable<GitRefConnectionFields> =>
queryGitReferences({ ...args, repo: props.repo, type: GitRefType.GIT_BRANCH, withBehindAhead: false })
const queryGitTags = (args: FilteredConnectionQueryArguments): Observable<GitRefConnectionFields> =>
queryGitReferences({ ...args, repo: props.repo, type: GitRefType.GIT_TAG, withBehindAhead: false })
const queryRepositoryCommits = (
args: FilteredConnectionQueryArguments
): Observable<GitCommitAncestorsConnectionFields> =>
fetchRepositoryCommits({
...args,
repo: props.repo,
revision: props.currentRev || props.defaultBranch,
})
const sharedPanelProps = {
className: 'connection-popover__content',
inputClassName: 'connection-popover__input',
listClassName: 'connection-popover__nodes',
inputPlaceholder: 'Find...',
compact: true,
autoFocus: true,
history: props.history,
location: props.location,
noSummaryIfAllNodesVisible: false,
useURLQuery: false,
}
return (
<Tabs defaultIndex={tabIndex} className="revisions-popover connection-popover" onChange={handleTabsChange}>
<div className="tablist-wrapper revisions-popover__tabs">
<TabList>
{TABS.map(({ label, id }) => (
<Tab key={id} data-tab-content={id}>
<span className="tablist-wrapper--tab-label">{label}</span>
</Tab>
))}
</TabList>
<button
onClick={props.togglePopover}
type="button"
className="btn btn-icon revisions-popover__tabs-close"
aria-label="Close"
>
<CloseIcon className="icon-inline" />
</button>
</div>
<TabPanels>
{TABS.map(tab => (
<TabPanel key={tab.id}>
{tab.type ? (
<FilteredConnection<GitRefFields, Omit<GitReferencePopoverNodeProps, 'node'>>
{...sharedPanelProps}
key={tab.id}
defaultFirst={50}
noun={tab.noun}
pluralNoun={tab.pluralNoun}
queryConnection={tab.type === GitRefType.GIT_BRANCH ? queryGitBranches : queryGitTags}
nodeComponent={GitReferencePopoverNode}
nodeComponentProps={{
defaultBranch: props.defaultBranch,
currentRevision: props.currentRev,
location: props.location,
}}
/>
) : (
<FilteredConnection<GitCommitAncestorFields, Omit<GitCommitNodeProps, 'node'>>
{...sharedPanelProps}
key={tab.id}
defaultFirst={15}
noun={tab.noun}
pluralNoun={tab.pluralNoun}
queryConnection={queryRepositoryCommits}
nodeComponent={GitCommitNode}
nodeComponentProps={{
currentCommitID: props.currentCommitID,
location: props.location,
}}
/>
)}
</TabPanel>
))}
</TabPanels>
</Tabs>
)
}

View File

@ -0,0 +1,378 @@
import { MockedResponse } from '@apollo/client/testing'
import { startOfYesterday } from 'date-fns'
import { GitRefType } from '@sourcegraph/shared/src/graphql-operations'
import { getDocumentNode } from '@sourcegraph/shared/src/graphql/graphql'
import {
GitCommitAncestorsConnectionFields,
GitRefConnectionFields,
RepositoryGitCommitResult,
RepositoryGitRefsResult,
} from '../../graphql-operations'
import { REPOSITORY_GIT_REFS } from '../GitReference'
import { RevisionsPopoverProps } from './RevisionsPopover'
import { REPOSITORY_GIT_COMMIT } from './RevisionsPopoverCommits'
export const MOCK_PROPS: RevisionsPopoverProps = {
repo: 'some-repo-id',
repoName: 'testorg/testrepo',
defaultBranch: 'main',
currentRev: 'main',
togglePopover: () => null,
showSpeculativeResults: false,
}
const yesterday = startOfYesterday().toISOString()
const commitPerson = {
displayName: 'display-name',
user: {
username: 'username',
},
}
const generateGitReferenceNodes = (nodeCount: number, variant: GitRefType): GitRefConnectionFields['nodes'] =>
new Array(nodeCount).fill(null).map((_value, index) => {
const id = `${variant}-${index}`
return {
__typename: 'GitRef',
id,
displayName: `${id}-display-name`,
abbrevName: `${id}-abbrev-name`,
name: `refs/heads/${id}`,
url: `/github.com/testorg/testrepo@${id}-display-name`,
target: {
commit: {
author: {
__typename: 'Signature',
date: yesterday,
person: commitPerson,
},
committer: {
__typename: 'Signature',
date: yesterday,
person: commitPerson,
},
behindAhead: null,
},
},
}
})
const generateGitCommitNodes = (nodeCount: number): GitCommitAncestorsConnectionFields['nodes'] =>
new Array(nodeCount).fill(null).map((_value, index) => ({
__typename: 'GitCommit',
id: `git-commit-${index}`,
oid: `git-commit-oid-${index}`,
abbreviatedOID: `git-commit-oid-${index}`,
author: {
person: {
name: commitPerson.displayName,
avatarURL: null,
},
date: yesterday,
},
subject: `Commit ${index}: Hello world`,
}))
const branchesMock: MockedResponse<RepositoryGitRefsResult> = {
request: {
query: getDocumentNode(REPOSITORY_GIT_REFS),
variables: {
query: '',
first: 50,
repo: MOCK_PROPS.repo,
type: GitRefType.GIT_BRANCH,
withBehindAhead: false,
},
},
result: {
data: {
node: {
__typename: 'Repository',
gitRefs: {
__typename: 'GitRefConnection',
totalCount: 100,
nodes: generateGitReferenceNodes(50, GitRefType.GIT_BRANCH),
pageInfo: {
hasNextPage: true,
},
},
},
},
},
}
const additionalBranchesMock: MockedResponse<RepositoryGitRefsResult> = {
request: {
...branchesMock.request,
variables: {
...branchesMock.request.variables,
first: 100,
},
},
result: {
data: {
node: {
__typename: 'Repository',
gitRefs: {
__typename: 'GitRefConnection',
totalCount: 100,
nodes: generateGitReferenceNodes(100, GitRefType.GIT_BRANCH),
pageInfo: {
hasNextPage: false,
},
},
},
},
},
}
const filteredBranchesMock: MockedResponse<RepositoryGitRefsResult> = {
request: {
...branchesMock.request,
variables: {
...branchesMock.request.variables,
query: 'some query',
},
},
result: {
data: {
node: {
__typename: 'Repository',
gitRefs: {
__typename: 'GitRefConnection',
totalCount: 2,
nodes: generateGitReferenceNodes(2, GitRefType.GIT_BRANCH),
pageInfo: {
hasNextPage: false,
},
},
},
},
},
}
const filteredBranchesNoResultsMock: MockedResponse<RepositoryGitRefsResult> = {
request: {
...branchesMock.request,
variables: {
...branchesMock.request.variables,
query: 'some other query',
},
},
result: {
data: {
node: {
__typename: 'Repository',
gitRefs: {
__typename: 'GitRefConnection',
totalCount: 0,
nodes: [],
pageInfo: {
hasNextPage: false,
},
},
},
},
},
}
const tagsMock: MockedResponse<RepositoryGitRefsResult> = {
request: {
query: getDocumentNode(REPOSITORY_GIT_REFS),
variables: {
query: '',
first: 50,
repo: MOCK_PROPS.repo,
type: GitRefType.GIT_TAG,
withBehindAhead: false,
},
},
result: {
data: {
node: {
__typename: 'Repository',
gitRefs: {
__typename: 'GitRefConnection',
totalCount: 100,
nodes: generateGitReferenceNodes(50, GitRefType.GIT_TAG),
pageInfo: {
hasNextPage: true,
},
},
},
},
},
}
const additionalTagsMock: MockedResponse<RepositoryGitRefsResult> = {
request: {
...tagsMock.request,
variables: {
...tagsMock.request.variables,
first: 100,
},
},
result: {
data: {
node: {
__typename: 'Repository',
gitRefs: {
__typename: 'GitRefConnection',
totalCount: 100,
nodes: generateGitReferenceNodes(100, GitRefType.GIT_TAG),
pageInfo: {
hasNextPage: false,
},
},
},
},
},
}
const filteredTagsMock: MockedResponse<RepositoryGitRefsResult> = {
request: {
...tagsMock.request,
variables: {
...tagsMock.request.variables,
query: 'some query',
},
},
result: {
data: {
node: {
__typename: 'Repository',
gitRefs: {
__typename: 'GitRefConnection',
totalCount: 2,
nodes: generateGitReferenceNodes(2, GitRefType.GIT_TAG),
pageInfo: {
hasNextPage: false,
},
},
},
},
},
}
const commitsMock: MockedResponse<RepositoryGitCommitResult> = {
request: {
query: getDocumentNode(REPOSITORY_GIT_COMMIT),
variables: {
query: '',
first: 15,
repo: MOCK_PROPS.repo,
revision: MOCK_PROPS.currentRev,
},
},
result: {
data: {
node: {
__typename: 'Repository',
commit: {
__typename: 'GitCommit',
ancestors: {
__typename: 'GitCommitConnection',
nodes: generateGitCommitNodes(15),
pageInfo: {
hasNextPage: true,
},
},
},
},
},
},
}
const additionalCommitsMock: MockedResponse<RepositoryGitCommitResult> = {
request: {
...commitsMock.request,
variables: {
...commitsMock.request.variables,
first: 30,
},
},
result: {
data: {
node: {
__typename: 'Repository',
commit: {
__typename: 'GitCommit',
ancestors: {
__typename: 'GitCommitConnection',
nodes: generateGitCommitNodes(30),
pageInfo: {
hasNextPage: false,
},
},
},
},
},
},
}
const filteredCommitsMock: MockedResponse<RepositoryGitCommitResult> = {
request: {
...commitsMock.request,
variables: {
...commitsMock.request.variables,
query: 'some query',
},
},
result: {
data: {
node: {
__typename: 'Repository',
commit: {
__typename: 'GitCommit',
ancestors: {
__typename: 'GitCommitConnection',
nodes: generateGitCommitNodes(2),
pageInfo: {
hasNextPage: false,
},
},
},
},
},
},
}
/**
* This mock is to test the case where a speculative revision is provided as context.
* In this case, the code should not error as it is still valid to display 0 results.
*/
const noCommitsMock: MockedResponse<RepositoryGitCommitResult> = {
request: {
...commitsMock.request,
variables: {
...commitsMock.request.variables,
revision: 'non-existent-revision',
},
},
result: {
data: {
node: {
__typename: 'Repository',
commit: null,
},
},
},
}
export const MOCK_REQUESTS = [
branchesMock,
additionalBranchesMock,
filteredBranchesMock,
filteredBranchesNoResultsMock,
tagsMock,
additionalTagsMock,
filteredTagsMock,
commitsMock,
additionalCommitsMock,
filteredCommitsMock,
noCommitsMock,
]

View File

@ -1,4 +1,4 @@
@import '../components/ConnectionPopover';
@import '../../components/ConnectionPopover';
.revisions-popover {
isolation: isolate;

View File

@ -0,0 +1,38 @@
import { Meta } from '@storybook/react'
import React from 'react'
import { WebStory } from '@sourcegraph/web/src/components/WebStory'
import { RevisionsPopover } from './RevisionsPopover'
import { MOCK_PROPS, MOCK_REQUESTS } from './RevisionsPopover.mocks'
const Story: Meta = {
title: 'web/RevisionsPopover',
decorators: [
story => (
<WebStory
mocks={MOCK_REQUESTS}
initialEntries={[{ pathname: `/${MOCK_PROPS.repoName}` }]}
// Can't utilise loose mocking here as the commit/branch requests use the same operations just with different variables
useStrictMocking={true}
>
{() => <div className="container mt-3">{story()}</div>}
</WebStory>
),
],
parameters: {
component: RevisionsPopover,
design: {
type: 'figma',
name: 'Figma',
url:
'https://www.figma.com/file/NIsN34NH7lPu04olBzddTw/Design-Refresh-Systemization-source-of-truth?node-id=954%3A2161',
},
},
}
export default Story
export const RevisionsPopoverExample = () => <RevisionsPopover {...MOCK_PROPS} />

View File

@ -0,0 +1,223 @@
import { cleanup, within, fireEvent, act } from '@testing-library/react'
import React from 'react'
import { MockedTestProvider, waitForNextApolloResponse } from '@sourcegraph/shared/src/testing/apollo'
import { renderWithRouter, RenderWithRouterResult } from '@sourcegraph/shared/src/testing/render-with-router'
import { RevisionsPopover, RevisionsPopoverProps } from './RevisionsPopover'
import { MOCK_PROPS, MOCK_REQUESTS } from './RevisionsPopover.mocks'
describe('RevisionsPopover', () => {
let renderResult: RenderWithRouterResult
const fetchMoreNodes = async (currentTab: HTMLElement) => {
fireEvent.click(within(currentTab).getByText('Show more'))
await waitForNextApolloResponse()
}
const renderPopover = (props?: Partial<RevisionsPopoverProps>): RenderWithRouterResult =>
renderWithRouter(
<MockedTestProvider mocks={MOCK_REQUESTS}>
<RevisionsPopover {...MOCK_PROPS} {...props} />
</MockedTestProvider>,
{ route: `/${MOCK_PROPS.repoName}` }
)
const waitForInputDebounce = () => act(() => new Promise(resolve => setTimeout(resolve, 200)))
afterEach(cleanup)
describe('Branches', () => {
let branchesTab: HTMLElement
beforeEach(async () => {
renderResult = renderPopover()
fireEvent.click(renderResult.getByText('Branches'))
await waitForNextApolloResponse()
branchesTab = renderResult.getByRole('tabpanel', { name: 'Branches' })
})
it('renders correct number of results', () => {
expect(within(branchesTab).getAllByRole('link')).toHaveLength(50)
expect(within(branchesTab).getByTestId('summary')).toHaveTextContent(
'100 branches total (showing first 50)'
)
expect(within(branchesTab).getByText('Show more')).toBeVisible()
})
it('renders result nodes correctly', () => {
const firstNode = within(branchesTab).getByText('GIT_BRANCH-0-display-name')
expect(firstNode).toBeVisible()
const firstLink = firstNode.closest('a')
expect(firstLink?.getAttribute('href')).toBe(`/${MOCK_PROPS.repoName}@GIT_BRANCH-0-abbrev-name`)
})
it('fetches remaining results correctly', async () => {
await fetchMoreNodes(branchesTab)
expect(within(branchesTab).getAllByRole('link')).toHaveLength(100)
expect(within(branchesTab).getByTestId('summary')).toHaveTextContent('100 branches total')
expect(within(branchesTab).queryByText('Show more')).not.toBeInTheDocument()
})
it('searches correctly', async () => {
const searchInput = within(branchesTab).getByRole('searchbox')
fireEvent.change(searchInput, { target: { value: 'some query' } })
await waitForInputDebounce()
await waitForNextApolloResponse()
expect(within(branchesTab).getAllByRole('link')).toHaveLength(2)
expect(within(branchesTab).getByTestId('summary')).toHaveTextContent('2 branches matching some query')
})
it('displays no results correctly', async () => {
const searchInput = within(branchesTab).getByRole('searchbox')
fireEvent.change(searchInput, { target: { value: 'some other query' } })
// Allow input to debounce
await act(() => new Promise(resolve => setTimeout(resolve, 200)))
await waitForNextApolloResponse()
expect(within(branchesTab).queryByRole('link')).not.toBeInTheDocument()
expect(within(branchesTab).getByTestId('summary')).toHaveTextContent(
'No branches matching some other query'
)
})
describe('Speculative results', () => {
beforeEach(async () => {
cleanup()
renderResult = renderPopover({ showSpeculativeResults: true })
fireEvent.click(renderResult.getByText('Branches'))
await waitForNextApolloResponse()
branchesTab = renderResult.getByRole('tabpanel', { name: 'Branches' })
})
it('displays results correctly by displaying a single speculative result', async () => {
const searchInput = within(branchesTab).getByRole('searchbox')
fireEvent.change(searchInput, { target: { value: 'some other query' } })
await waitForInputDebounce()
await waitForNextApolloResponse()
expect(within(branchesTab).getByRole('link')).toBeInTheDocument()
const firstNode = within(branchesTab).getByText('some other query')
expect(firstNode).toBeVisible()
const firstLink = firstNode.closest('a')
expect(firstLink?.getAttribute('href')).toBe(`/${MOCK_PROPS.repoName}@some%20other%20query`)
})
})
})
describe('Tags', () => {
let tagsTab: HTMLElement
beforeEach(async () => {
renderResult = renderPopover()
fireEvent.click(renderResult.getByText('Tags'))
await waitForNextApolloResponse()
tagsTab = renderResult.getByRole('tabpanel', { name: 'Tags' })
})
it('renders correct number of results', () => {
expect(within(tagsTab).getAllByRole('link')).toHaveLength(50)
expect(within(tagsTab).getByTestId('summary')).toHaveTextContent('100 tags total (showing first 50)')
expect(within(tagsTab).getByText('Show more')).toBeVisible()
})
it('renders result nodes correctly', () => {
const firstNode = within(tagsTab).getByText('GIT_TAG-0-display-name')
expect(firstNode).toBeVisible()
const firstLink = firstNode.closest('a')
expect(firstLink?.getAttribute('href')).toBe(`/${MOCK_PROPS.repoName}@GIT_TAG-0-abbrev-name`)
})
it('fetches remaining results correctly', async () => {
await fetchMoreNodes(tagsTab)
expect(within(tagsTab).getAllByRole('link')).toHaveLength(100)
expect(within(tagsTab).getByTestId('summary')).toHaveTextContent('100 tags total')
expect(within(tagsTab).queryByText('Show more')).not.toBeInTheDocument()
})
it('searches correctly', async () => {
const searchInput = within(tagsTab).getByRole('searchbox')
fireEvent.change(searchInput, { target: { value: 'some query' } })
await waitForInputDebounce()
await waitForNextApolloResponse()
expect(within(tagsTab).getAllByRole('link')).toHaveLength(2)
expect(within(tagsTab).getByTestId('summary')).toHaveTextContent('2 tags matching some query')
})
})
describe('Commits', () => {
let commitsTab: HTMLElement
beforeEach(async () => {
renderResult = renderPopover()
fireEvent.click(renderResult.getByText('Commits'))
await waitForNextApolloResponse()
commitsTab = renderResult.getByRole('tabpanel', { name: 'Commits' })
})
it('renders correct number of results', () => {
expect(within(commitsTab).getAllByRole('link')).toHaveLength(15)
expect(within(commitsTab).getByText('Show more')).toBeVisible()
})
it('renders result nodes correctly', () => {
const firstNode = within(commitsTab).getByText('git-commit-oid-0')
expect(firstNode).toBeVisible()
expect(within(commitsTab).getByText('Commit 0: Hello world')).toBeVisible()
expect(firstNode.closest('a')?.getAttribute('href')).toBe(`/${MOCK_PROPS.repoName}@git-commit-oid-0`)
})
it('fetches remaining results correctly', async () => {
await fetchMoreNodes(commitsTab)
expect(within(commitsTab).getAllByRole('link')).toHaveLength(30)
expect(within(commitsTab).queryByText('Show more')).not.toBeInTheDocument()
})
it('searches correctly', async () => {
const searchInput = within(commitsTab).getByRole('searchbox')
fireEvent.change(searchInput, { target: { value: 'some query' } })
await waitForInputDebounce()
await waitForNextApolloResponse()
expect(within(commitsTab).getAllByRole('link')).toHaveLength(2)
expect(within(commitsTab).getByTestId('summary')).toHaveTextContent('2 commits matching some query')
})
describe('Against a speculative revision', () => {
beforeEach(async () => {
cleanup()
renderResult = renderPopover({ currentRev: 'non-existent-revision' })
fireEvent.click(renderResult.getByText('Commits'))
await waitForNextApolloResponse()
commitsTab = renderResult.getByRole('tabpanel', { name: 'Commits' })
})
it('renders 0 results', () => {
expect(within(commitsTab).queryByRole('link')).not.toBeInTheDocument()
expect(within(commitsTab).queryByText('Show more')).not.toBeInTheDocument()
expect(within(commitsTab).getByTestId('summary')).toHaveTextContent('No commits')
})
})
})
})

View File

@ -0,0 +1,129 @@
import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@reach/tabs'
import CloseIcon from 'mdi-react/CloseIcon'
import React, { useCallback, useEffect } from 'react'
import { GitRefType, Scalars } from '@sourcegraph/shared/src/graphql-operations'
import { useLocalStorage } from '@sourcegraph/shared/src/util/useLocalStorage'
import { GitCommitAncestorFields, GitRefFields } from '../../graphql-operations'
import { eventLogger } from '../../tracking/eventLogger'
import { replaceRevisionInURL } from '../../util/url'
import { RevisionsPopoverCommits } from './RevisionsPopoverCommits'
import { RevisionsPopoverReferences } from './RevisionsPopoverReferences'
export interface RevisionsPopoverProps {
repo: Scalars['ID']
repoName: string
defaultBranch: string
/** The current revision, or undefined for the default branch. */
currentRev: string | undefined
currentCommitID?: string
/* Callback to dismiss the parent popover wrapper */
togglePopover: () => void
/* Determine the URL to use for each revision node */
getPathFromRevision?: (href: string, revision: string) => string
/**
* If the popover should display result nodes that are not **known** revisions.
* This ensures we can support ancestory-based revision queries (e.g. `main^1`).
*/
showSpeculativeResults?: boolean
/**
* The selected revision node. Should be used to trigger side effects from clicking a node, e.g. calling `eventLogger`.
*/
onSelect?: (node: GitRefFields | GitCommitAncestorFields) => void
}
type RevisionsPopoverTabID = 'branches' | 'tags' | 'commits'
interface RevisionsPopoverTab {
id: RevisionsPopoverTabID
label: string
noun: string
pluralNoun: string
type?: GitRefType
}
const LAST_TAB_STORAGE_KEY = 'RevisionsPopover.lastTab'
const TABS: RevisionsPopoverTab[] = [
{ id: 'branches', label: 'Branches', noun: 'branch', pluralNoun: 'branches', type: GitRefType.GIT_BRANCH },
{ id: 'tags', label: 'Tags', noun: 'tag', pluralNoun: 'tags', type: GitRefType.GIT_TAG },
{ id: 'commits', label: 'Commits', noun: 'commit', pluralNoun: 'commits' },
]
/**
* A popover that displays a searchable list of revisions (grouped by type) for
* the current repository.
*/
export const RevisionsPopover: React.FunctionComponent<RevisionsPopoverProps> = props => {
const { getPathFromRevision = replaceRevisionInURL } = props
useEffect(() => {
eventLogger.logViewEvent('RevisionsPopover')
}, [])
const [tabIndex, setTabIndex] = useLocalStorage(LAST_TAB_STORAGE_KEY, 0)
const handleTabsChange = useCallback((index: number) => setTabIndex(index), [setTabIndex])
return (
<Tabs defaultIndex={tabIndex} className="revisions-popover connection-popover" onChange={handleTabsChange}>
<div className="tablist-wrapper revisions-popover__tabs">
<TabList>
{TABS.map(({ label, id }) => (
<Tab key={id} data-tab-content={id}>
<span className="tablist-wrapper--tab-label">{label}</span>
</Tab>
))}
</TabList>
<button
onClick={props.togglePopover}
type="button"
className="btn btn-icon revisions-popover__tabs-close"
aria-label="Close"
>
<CloseIcon className="icon-inline" />
</button>
</div>
<TabPanels>
{TABS.map(tab => (
<TabPanel key={tab.id}>
{tab.type ? (
<RevisionsPopoverReferences
noun={tab.noun}
pluralNoun={tab.pluralNoun}
type={tab.type}
currentRev={props.currentRev}
getPathFromRevision={getPathFromRevision}
defaultBranch={props.defaultBranch}
repo={props.repo}
repoName={props.repoName}
onSelect={props.onSelect}
showSpeculativeResults={
props.showSpeculativeResults && tab.type === GitRefType.GIT_BRANCH
}
/>
) : (
<RevisionsPopoverCommits
noun={tab.noun}
pluralNoun={tab.pluralNoun}
currentRev={props.currentRev}
getPathFromRevision={getPathFromRevision}
defaultBranch={props.defaultBranch}
repo={props.repo}
currentCommitID={props.currentCommitID}
onSelect={props.onSelect}
/>
)}
</TabPanel>
))}
</TabPanels>
</Tabs>
)
}

View File

@ -0,0 +1,200 @@
import classNames from 'classnames'
import * as H from 'history'
import React, { useState } from 'react'
import { useLocation } from 'react-router'
import { Link } from 'react-router-dom'
import { Scalars } from '@sourcegraph/shared/src/graphql-operations'
import { dataOrThrowErrors, gql } from '@sourcegraph/shared/src/graphql/graphql'
import { useConnection } from '@sourcegraph/web/src/components/FilteredConnection/hooks/useConnection'
import { ConnectionSummary } from '@sourcegraph/web/src/components/FilteredConnection/ui'
import { useDebounce } from '@sourcegraph/wildcard'
import {
GitCommitAncestorFields,
RepositoryGitCommitResult,
RepositoryGitCommitVariables,
} from '../../graphql-operations'
import { RevisionsPopoverTab } from './RevisionsPopoverTab'
export const REPOSITORY_GIT_COMMIT = gql`
query RepositoryGitCommit($repo: ID!, $first: Int, $revision: String!, $query: String) {
node(id: $repo) {
__typename
... on Repository {
commit(rev: $revision) {
__typename
ancestors(first: $first, query: $query) {
__typename
...GitCommitAncestorsConnectionFields
}
}
}
}
}
fragment GitCommitAncestorsConnectionFields on GitCommitConnection {
nodes {
__typename
...GitCommitAncestorFields
}
pageInfo {
hasNextPage
}
}
fragment GitCommitAncestorFields on GitCommit {
id
oid
abbreviatedOID
author {
person {
name
avatarURL
}
date
}
subject
}
`
interface GitCommitNodeProps {
node: GitCommitAncestorFields
currentCommitID: string | undefined
location: H.Location
getPathFromRevision: (href: string, revision: string) => string
onClick?: React.MouseEventHandler<HTMLAnchorElement>
}
const GitCommitNode: React.FunctionComponent<GitCommitNodeProps> = ({
node,
currentCommitID,
location,
getPathFromRevision,
onClick,
}) => {
const isCurrent = currentCommitID === node.oid
return (
<li key={node.oid} className="connection-popover__node revisions-popover-git-commit-node">
<Link
to={getPathFromRevision(location.pathname + location.search + location.hash, node.oid)}
className={classNames(
'connection-popover__node-link',
isCurrent && 'connection-popover__node-link--active',
'revisions-popover-git-commit-node__link'
)}
onClick={onClick}
>
<code className="badge" title={node.oid}>
{node.abbreviatedOID}
</code>
<small className="revisions-popover-git-commit-node__message">{node.subject.slice(0, 200)}</small>
</Link>
</li>
)
}
interface RevisionsPopoverCommitsProps {
repo: Scalars['ID']
defaultBranch: string
getPathFromRevision: (href: string, revision: string) => string
noun: string
pluralNoun: string
/** The current revision, or undefined for the default branch. */
currentRev: string | undefined
currentCommitID?: string
onSelect?: (node: GitCommitAncestorFields) => void
}
const BATCH_COUNT = 15
export const RevisionsPopoverCommits: React.FunctionComponent<RevisionsPopoverCommitsProps> = ({
repo,
defaultBranch,
getPathFromRevision,
currentRev,
noun,
pluralNoun,
currentCommitID,
onSelect,
}) => {
const [searchValue, setSearchValue] = useState('')
const query = useDebounce(searchValue, 200)
const location = useLocation()
const response = useConnection<RepositoryGitCommitResult, RepositoryGitCommitVariables, GitCommitAncestorFields>({
query: REPOSITORY_GIT_COMMIT,
variables: {
query,
first: BATCH_COUNT,
repo,
revision: currentRev || defaultBranch,
},
getConnection: response => {
const { node } = dataOrThrowErrors(response)
if (!node) {
throw new Error(`Repository ${repo} not found`)
}
if (node.__typename !== 'Repository') {
throw new Error(`Node is a ${node.__typename}, not a Repository`)
}
if (!node.commit) {
// Did not find a commit for the current revision, the user may have provided an invalid revision.
// Avoid erroring here so this can be reflected correctly in the UI.
return {
nodes: [],
}
}
if (!node.commit.ancestors) {
throw new Error(`Cannot load ancestors for repository ${repo}`)
}
return node.commit.ancestors
},
})
const summary = response.connection && (
<ConnectionSummary
connection={response.connection}
first={BATCH_COUNT}
noun={noun}
pluralNoun={pluralNoun}
hasNextPage={response.hasNextPage}
connectionQuery={query}
/>
)
return (
<RevisionsPopoverTab
{...response}
query={query}
summary={summary}
inputValue={searchValue}
onInputChange={setSearchValue}
>
{response.connection?.nodes?.map((node, index) => (
<GitCommitNode
key={index}
node={node}
currentCommitID={currentCommitID}
location={location}
getPathFromRevision={getPathFromRevision}
onClick={() => onSelect?.(node)}
/>
))}
</RevisionsPopoverTab>
)
}

View File

@ -0,0 +1,209 @@
import classNames from 'classnames'
import * as H from 'history'
import SearchIcon from 'mdi-react/SearchIcon'
import React, { useState } from 'react'
import { useLocation } from 'react-router'
import { GitRefType, Scalars } from '@sourcegraph/shared/src/graphql-operations'
import { createAggregateError } from '@sourcegraph/shared/src/util/errors'
import { escapeRevspecForURL } from '@sourcegraph/shared/src/util/url'
import { useConnection } from '@sourcegraph/web/src/components/FilteredConnection/hooks/useConnection'
import { ConnectionSummary } from '@sourcegraph/web/src/components/FilteredConnection/ui'
import { useDebounce } from '@sourcegraph/wildcard'
import { GitRefFields, RepositoryGitRefsResult, RepositoryGitRefsVariables } from '../../graphql-operations'
import { GitReferenceNode, GitReferenceNodeProps, REPOSITORY_GIT_REFS } from '../GitReference'
import { RevisionsPopoverTab } from './RevisionsPopoverTab'
interface GitReferencePopoverNodeProps extends Pick<GitReferenceNodeProps, 'node' | 'onClick'> {
defaultBranch: string
currentRevision: string | undefined
location: H.Location
getPathFromRevision: (href: string, revision: string) => string
isSpeculative?: boolean
}
const GitReferencePopoverNode: React.FunctionComponent<GitReferencePopoverNodeProps> = ({
node,
defaultBranch,
currentRevision,
location,
getPathFromRevision,
isSpeculative,
onClick,
}) => {
let isCurrent: boolean
if (currentRevision) {
isCurrent = node.name === currentRevision || node.abbrevName === currentRevision
} else {
isCurrent = node.name === `refs/heads/${defaultBranch}`
}
return (
<GitReferenceNode
node={node}
url={getPathFromRevision(location.pathname + location.search + location.hash, node.abbrevName)}
ancestorIsLink={false}
className={classNames(
'connection-popover__node-link',
isCurrent && 'connection-popover__node-link--active'
)}
onClick={onClick}
icon={isSpeculative ? SearchIcon : undefined}
/>
)
}
interface SpectulativeGitReferencePopoverNodeProps
extends Pick<RevisionsPopoverReferencesProps, 'onSelect'>,
Omit<GitReferencePopoverNodeProps, 'node'> {
name: string
repoName: string
existingNodes: GitRefFields[]
}
export const SpectulativeGitReferencePopoverNode: React.FunctionComponent<SpectulativeGitReferencePopoverNodeProps> = ({
name,
repoName,
currentRevision,
defaultBranch,
getPathFromRevision,
location,
existingNodes,
onSelect,
}) => {
const alreadyExists = existingNodes.some(existingNode => existingNode.abbrevName === name)
if (alreadyExists) {
// We're already showing this node, so don't show it again.
return null
}
/**
* A dummy GitReferenceNode that we can use to render a possible result in the same styles as existing, known, results.
*/
const speculativeGitNode: GitReferenceNodeProps['node'] | null = {
id: name,
name,
displayName: name,
abbrevName: name,
url: `/${repoName}@${escapeRevspecForURL(name)}`,
target: { commit: null },
}
// We haven't found a node with the same name, render a node with expected props
return (
<GitReferencePopoverNode
node={speculativeGitNode}
currentRevision={currentRevision}
defaultBranch={defaultBranch}
getPathFromRevision={getPathFromRevision}
location={location}
onClick={() => onSelect?.(speculativeGitNode)}
isSpeculative={true}
/>
)
}
interface RevisionsPopoverReferencesProps {
type: GitRefType
repo: Scalars['ID']
repoName: string
defaultBranch: string
getPathFromRevision: (href: string, revision: string) => string
noun: string
pluralNoun: string
/** The current revision, or undefined for the default branch. */
currentRev: string | undefined
showSpeculativeResults?: boolean
onSelect?: (node: GitRefFields) => void
}
const BATCH_COUNT = 50
export const RevisionsPopoverReferences: React.FunctionComponent<RevisionsPopoverReferencesProps> = ({
type,
repo,
repoName,
defaultBranch,
getPathFromRevision,
currentRev,
noun,
pluralNoun,
showSpeculativeResults,
onSelect,
}) => {
const [searchValue, setSearchValue] = useState('')
const query = useDebounce(searchValue, 200)
const location = useLocation()
const response = useConnection<RepositoryGitRefsResult, RepositoryGitRefsVariables, GitRefFields>({
query: REPOSITORY_GIT_REFS,
variables: {
query,
first: BATCH_COUNT,
repo,
type,
withBehindAhead: false,
},
getConnection: ({ data, errors }) => {
if (!data || !data.node || data.node.__typename !== 'Repository' || !data.node.gitRefs) {
throw createAggregateError(errors)
}
return data.node.gitRefs
},
})
const summary = response.connection && (
<ConnectionSummary
emptyElement={showSpeculativeResults ? <></> : undefined}
connection={response.connection}
first={BATCH_COUNT}
noun={noun}
pluralNoun={pluralNoun}
hasNextPage={response.hasNextPage}
connectionQuery={query}
/>
)
return (
<RevisionsPopoverTab
{...response}
query={query}
summary={summary}
inputValue={searchValue}
onInputChange={setSearchValue}
>
{response.connection?.nodes.map((node, index) => (
<GitReferencePopoverNode
key={index}
node={node}
currentRevision={currentRev}
defaultBranch={defaultBranch}
getPathFromRevision={getPathFromRevision}
location={location}
onClick={() => onSelect?.(node)}
/>
))}
{showSpeculativeResults && response.connection && query && (
<SpectulativeGitReferencePopoverNode
name={query}
repoName={repoName}
existingNodes={response.connection.nodes}
currentRevision={currentRev}
defaultBranch={defaultBranch}
getPathFromRevision={getPathFromRevision}
location={location}
onSelect={onSelect}
/>
)}
</RevisionsPopoverTab>
)
}

View File

@ -0,0 +1,52 @@
import React from 'react'
import { UseConnectionResult } from '@sourcegraph/web/src/components/FilteredConnection/hooks/useConnection'
import {
ConnectionContainer,
ConnectionError,
ConnectionForm,
ConnectionList,
ConnectionLoading,
ShowMoreButton,
SummaryContainer,
} from '@sourcegraph/web/src/components/FilteredConnection/ui'
interface RevisionsPopoverTabProps extends UseConnectionResult<unknown> {
inputValue: string
onInputChange: (value: string) => void
query: string
summary?: JSX.Element
}
export const RevisionsPopoverTab: React.FunctionComponent<RevisionsPopoverTabProps> = ({
children,
inputValue,
onInputChange,
query,
summary,
error,
loading,
connection,
hasNextPage,
fetchMore,
}) => (
<ConnectionContainer compact={true} className="connection-popover__content">
<ConnectionForm
inputValue={inputValue}
onInputChange={event => onInputChange(event.target.value)}
autoFocus={true}
inputPlaceholder="Find..."
inputClassName="connection-popover__input"
/>
<SummaryContainer>{query && summary}</SummaryContainer>
{error && <ConnectionError errors={[error.message]} />}
<ConnectionList className="connection-popover__nodes">{children}</ConnectionList>
{loading && <ConnectionLoading />}
{!loading && connection && (
<SummaryContainer>
{!query && summary}
{hasNextPage && <ShowMoreButton onClick={fetchMore} />}
</SummaryContainer>
)}
</ConnectionContainer>
)

View File

@ -0,0 +1 @@
export * from './RevisionsPopover'

View File

@ -1,9 +1,10 @@
import { MockedProvider, MockedResponse } from '@apollo/client/testing'
import { MockedResponse } from '@apollo/client/testing'
import { fireEvent, render, RenderResult, act } from '@testing-library/react'
import React from 'react'
import { MemoryRouter } from 'react-router'
import { getDocumentNode } from '@sourcegraph/shared/src/graphql/graphql'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
import { UPDATE_USER } from './EditUserProfileForm'
import { UserSettingsProfilePage } from './UserSettingsProfilePage'
@ -52,11 +53,11 @@ describe('UserSettingsProfilePage', () => {
beforeEach(() => {
queries = render(
<MockedProvider mocks={mocks}>
<MockedTestProvider mocks={mocks}>
<MemoryRouter>
<UserSettingsProfilePage user={mockUser} />
</MemoryRouter>
</MockedProvider>
</MockedTestProvider>
)
})

View File

@ -2,3 +2,4 @@ export { useDebounce } from './useDebounce'
export { useControlledState } from './useControlledState'
export { useOffsetPagination } from './useOffsetPagination'
export { useSearchParameters } from './useSearchParameters'
export { useAutoFocus } from './useAutoFocus'

View File

@ -0,0 +1,23 @@
import { useEffect, RefObject } from 'react'
interface UseAutoFocusParameters {
autoFocus?: boolean
reference: RefObject<HTMLElement>
}
/**
* Hook to ensure that an element is focused correctly.
* Relying on the `autoFocus` attribute is not reliable within React.
* https://reactjs.org/docs/accessibility.html#programmatically-managing-focus
*/
export const useAutoFocus = ({ autoFocus, reference }: UseAutoFocusParameters): void => {
useEffect(() => {
if (autoFocus) {
requestAnimationFrame(() => {
reference.current?.focus()
})
}
// Reference will not change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoFocus])
}

View File

@ -130,10 +130,10 @@ Lastly, for the request data that you *do* choose to cache, it's best to make de
Apollo lets us easily mock queries in our tests without having to actually mock out our own logic. The tests will fail if an un-mocked query fires through Apollo, so it is important to accurately build mock requests. In order to test how the UI displays a response, you can provide a mocked result. See this example:
```ts
import { MockedProvider } from '@apollo/client/testing'
import { render } from '@testing-library/react'
import { getDocumentNode } from '@sourcegraph/shared/src/graphql/graphql'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
import { MyComponent, USER_DISPLAY_NAME } from './MyComponent'
@ -158,9 +158,9 @@ const mocks = [
describe('My Test', () => {
it('works', () => {
const { getByText } = render(
<MockedProvider mocks={mocks}>
<MockedTestProvider mocks={mocks}>
<MyComponent />
</MockedProvider>
</MockedTestProvider>
)
expect(getByText('Your display name is: Mock DisplayName')).toBeVisible();
})

View File

@ -393,6 +393,7 @@
"textarea-caret": "^3.1.0",
"ts-key-enum": "^2.0.7",
"tslib": "^2.1.0",
"use-callback-ref": "^1.2.5",
"use-deep-compare-effect": "^1.6.1",
"use-resize-observer": "^7.0.0",
"utility-types": "^3.10.0",

View File

@ -22978,7 +22978,7 @@ url@^0.11.0:
punycode "1.3.2"
querystring "0.2.0"
use-callback-ref@^1.2.1, use-callback-ref@^1.2.3:
use-callback-ref@^1.2.1, use-callback-ref@^1.2.3, use-callback-ref@^1.2.5:
version "1.2.5"
resolved "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.2.5.tgz#6115ed242cfbaed5915499c0a9842ca2912f38a5"
integrity sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg==