mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 15:51:43 +00:00
RevisionsPopover: Refactor and support speculative results (#23973)
This commit is contained in:
parent
45ce316b7e
commit
364145452b
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)))
|
||||
34
client/shared/src/testing/apollo.tsx
Normal file
34
client/shared/src/testing/apollo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -34,6 +34,9 @@
|
||||
}
|
||||
|
||||
&__nodes {
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
.theme-redesign & {
|
||||
border-top: solid 1px var(--border-color-2);
|
||||
padding-top: 0.25rem;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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)}>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
@import './GitReference';
|
||||
@import '../marketing/Toast';
|
||||
@import './RepoRevisionSidebar';
|
||||
@import './RevisionsPopover';
|
||||
@import './RevisionsPopover/RevisionsPopover';
|
||||
@import './docs/RepositoryDocumentationPage';
|
||||
@import './commits/RepositoryCommitsPage';
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
378
client/web/src/repo/RevisionsPopover/RevisionsPopover.mocks.ts
Normal file
378
client/web/src/repo/RevisionsPopover/RevisionsPopover.mocks.ts
Normal 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,
|
||||
]
|
||||
@ -1,4 +1,4 @@
|
||||
@import '../components/ConnectionPopover';
|
||||
@import '../../components/ConnectionPopover';
|
||||
|
||||
.revisions-popover {
|
||||
isolation: isolate;
|
||||
@ -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} />
|
||||
223
client/web/src/repo/RevisionsPopover/RevisionsPopover.test.tsx
Normal file
223
client/web/src/repo/RevisionsPopover/RevisionsPopover.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
129
client/web/src/repo/RevisionsPopover/RevisionsPopover.tsx
Normal file
129
client/web/src/repo/RevisionsPopover/RevisionsPopover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
200
client/web/src/repo/RevisionsPopover/RevisionsPopoverCommits.tsx
Normal file
200
client/web/src/repo/RevisionsPopover/RevisionsPopoverCommits.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
52
client/web/src/repo/RevisionsPopover/RevisionsPopoverTab.tsx
Normal file
52
client/web/src/repo/RevisionsPopover/RevisionsPopoverTab.tsx
Normal 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>
|
||||
)
|
||||
1
client/web/src/repo/RevisionsPopover/index.ts
Normal file
1
client/web/src/repo/RevisionsPopover/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './RevisionsPopover'
|
||||
@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -2,3 +2,4 @@ export { useDebounce } from './useDebounce'
|
||||
export { useControlledState } from './useControlledState'
|
||||
export { useOffsetPagination } from './useOffsetPagination'
|
||||
export { useSearchParameters } from './useSearchParameters'
|
||||
export { useAutoFocus } from './useAutoFocus'
|
||||
|
||||
23
client/wildcard/src/hooks/useAutoFocus.ts
Normal file
23
client/wildcard/src/hooks/useAutoFocus.ts
Normal 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])
|
||||
}
|
||||
@ -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();
|
||||
})
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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==
|
||||
|
||||
Loading…
Reference in New Issue
Block a user