mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 18:51:59 +00:00
make pagination hooks store filter & query params in URL, not just pagination params (#63744)
This is a refactor with a few incidental user-facing changes (eg not
showing a sometimes-incorrect total count in the UI).
---
Make `usePageSwitcherPagination` and `useShowMorePaginationUrl` support
storing filter and query params, not just pagination params, in the URL.
This is commonly desired behavior, and there are many ways we do it
across the codebase. This attempts to standardize how it's done. It does
not update all places this is done to standardize them yet.
Previously, you could use the `options: { useURL: true}` arg to
`usePageSwitcherPagination` and `useShowMorePaginationUrl`. This was not
good because it only updated the pagination URL querystring params and
not the filter params. Some places had a manual way to update the filter
params, but it was incorrect (reloading the page would not get you back
to the same view state) and had a lot of duplicated code. There was
actually no way to have everything (filters and pagination params)
updated in the URL all together, except using the deprecated
`<FilteredConnection>`.
Now, callers that want the URL to be updated with the connection state
(including pagination *and* filters) do:
```typescript
const connectionState = useUrlSearchParamsForConnectionState(filters)
const { ... } = usePageSwitcherPagination({ query: ..., state: connectionState}) // or useShowMorePaginationUrl
```
Callers that do not want the connection state to be reflected in the URL
can just not pass any `state:` value.
This PR also has some other refactors:
- remove `<ConnectionSummary first>` that was used in an erroneous
calculation. It was only used as a hack to determine the `totalCount` of
the connection. This is usually returned in the connection result itself
and that is the only value that should be used.
- remove `?visible=N` param from some connection pages, just use
`?first=N`. This was intended to make it so that if you reloaded or
navigated directly to a page that had a list, subsequently fetching the
next page would only get `pageSize` additional records (eg 20), not
`first` (which is however many records were already showing) for
batch-based navigation. This is not worth the additional complexity that
`?visible=` introduces, and is not clearly desirable even.
- 2 other misc. ones that do not affect user-facing behavior (see commit
messages)
## Test plan
Visit the site admin repositories and packages pages. Ensure all filter
options work and pagination works.
This commit is contained in:
parent
b71c986c77
commit
e73efbe1de
@ -320,9 +320,9 @@ ts_project(
|
||||
"src/components/FilteredConnection/FilterControl.tsx",
|
||||
"src/components/FilteredConnection/FilteredConnection.tsx",
|
||||
"src/components/FilteredConnection/constants.ts",
|
||||
"src/components/FilteredConnection/hooks/connectionState.ts",
|
||||
"src/components/FilteredConnection/hooks/usePageSwitcherPagination.ts",
|
||||
"src/components/FilteredConnection/hooks/useShowMorePagination.ts",
|
||||
"src/components/FilteredConnection/hooks/useShowMorePaginationUrl.ts",
|
||||
"src/components/FilteredConnection/index.ts",
|
||||
"src/components/FilteredConnection/ui/ConnectionContainer.tsx",
|
||||
"src/components/FilteredConnection/ui/ConnectionError.tsx",
|
||||
@ -1895,6 +1895,7 @@ ts_project(
|
||||
"src/components/FilteredConnection/FilteredConnection.test.tsx",
|
||||
"src/components/FilteredConnection/hooks/usePageSwitcherPagination.test.tsx",
|
||||
"src/components/FilteredConnection/hooks/useShowMorePagination.test.tsx",
|
||||
"src/components/FilteredConnection/utils.test.ts",
|
||||
"src/components/KeyboardShortcutsHelp/KeyboardShortcutsHelp.test.tsx",
|
||||
"src/components/LoaderButton.test.tsx",
|
||||
"src/components/WebStory.tsx",
|
||||
|
||||
@ -4,28 +4,27 @@ import type { MockedResponse } from '@apollo/client/testing'
|
||||
import { of } from 'rxjs'
|
||||
|
||||
import { logger } from '@sourcegraph/common'
|
||||
import { getDocumentNode, dataOrThrowErrors, useQuery } from '@sourcegraph/http-client'
|
||||
import { dataOrThrowErrors, getDocumentNode, useQuery } from '@sourcegraph/http-client'
|
||||
import { noOpTelemetryRecorder } from '@sourcegraph/shared/src/telemetry'
|
||||
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import { NOOP_PLATFORM_CONTEXT } from '@sourcegraph/shared/src/testing/searchTestHelpers'
|
||||
|
||||
import type { ConnectionQueryArguments } from '../components/FilteredConnection'
|
||||
import { asGraphQLResult } from '../components/FilteredConnection/utils'
|
||||
import {
|
||||
type UsePreciseCodeIntelForPositionResult,
|
||||
type UsePreciseCodeIntelForPositionVariables,
|
||||
HighlightResponseFormat,
|
||||
type LocationFields,
|
||||
type ReferencesPanelHighlightedBlobVariables,
|
||||
type ResolveRepoAndRevisionVariables,
|
||||
type UsePreciseCodeIntelForPositionResult,
|
||||
type UsePreciseCodeIntelForPositionVariables,
|
||||
} from '../graphql-operations'
|
||||
|
||||
import { buildPreciseLocation, LocationsGroup } from './location'
|
||||
import type { ReferencesPanelProps } from './ReferencesPanel'
|
||||
import {
|
||||
USE_PRECISE_CODE_INTEL_FOR_POSITION_QUERY,
|
||||
RESOLVE_REPO_REVISION_BLOB_QUERY,
|
||||
FETCH_HIGHLIGHTED_BLOB,
|
||||
RESOLVE_REPO_REVISION_BLOB_QUERY,
|
||||
USE_PRECISE_CODE_INTEL_FOR_POSITION_QUERY,
|
||||
} from './ReferencesPanelQueries'
|
||||
import type { UseCodeIntelParameters, UseCodeIntelResult } from './useCodeIntel'
|
||||
|
||||
@ -718,45 +717,45 @@ export const defaultProps: ReferencesPanelProps = {
|
||||
fetchMorePrototypesLoading: false,
|
||||
fetchMorePrototypes: () => {},
|
||||
})
|
||||
useQuery<
|
||||
UsePreciseCodeIntelForPositionResult,
|
||||
UsePreciseCodeIntelForPositionVariables & ConnectionQueryArguments
|
||||
>(USE_PRECISE_CODE_INTEL_FOR_POSITION_QUERY, {
|
||||
variables,
|
||||
notifyOnNetworkStatusChange: false,
|
||||
fetchPolicy: 'no-cache',
|
||||
skip: !result.loading,
|
||||
onCompleted: result => {
|
||||
const data = dataOrThrowErrors(asGraphQLResult({ data: result, errors: [] }))
|
||||
if (!data?.repository?.commit?.blob?.lsif) {
|
||||
return
|
||||
}
|
||||
const lsif = data.repository.commit.blob.lsif
|
||||
setResult(prevResult => ({
|
||||
...prevResult,
|
||||
loading: false,
|
||||
data: {
|
||||
implementations: {
|
||||
endCursor: lsif.implementations.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.implementations.nodes.map(buildPreciseLocation)),
|
||||
},
|
||||
prototypes: {
|
||||
endCursor: lsif.prototypes.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.prototypes.nodes.map(buildPreciseLocation)),
|
||||
},
|
||||
useQuery<UsePreciseCodeIntelForPositionResult, UsePreciseCodeIntelForPositionVariables>(
|
||||
USE_PRECISE_CODE_INTEL_FOR_POSITION_QUERY,
|
||||
{
|
||||
variables,
|
||||
notifyOnNetworkStatusChange: false,
|
||||
fetchPolicy: 'no-cache',
|
||||
skip: !result.loading,
|
||||
onCompleted: result => {
|
||||
const data = dataOrThrowErrors(asGraphQLResult({ data: result, errors: [] }))
|
||||
if (!data?.repository?.commit?.blob?.lsif) {
|
||||
return
|
||||
}
|
||||
const lsif = data.repository.commit.blob.lsif
|
||||
setResult(prevResult => ({
|
||||
...prevResult,
|
||||
loading: false,
|
||||
data: {
|
||||
implementations: {
|
||||
endCursor: lsif.implementations.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.implementations.nodes.map(buildPreciseLocation)),
|
||||
},
|
||||
prototypes: {
|
||||
endCursor: lsif.prototypes.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.prototypes.nodes.map(buildPreciseLocation)),
|
||||
},
|
||||
|
||||
references: {
|
||||
endCursor: lsif.references.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.references.nodes.map(buildPreciseLocation)),
|
||||
references: {
|
||||
endCursor: lsif.references.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.references.nodes.map(buildPreciseLocation)),
|
||||
},
|
||||
definitions: {
|
||||
endCursor: lsif.definitions.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.definitions.nodes.map(buildPreciseLocation)),
|
||||
},
|
||||
},
|
||||
definitions: {
|
||||
endCursor: lsif.definitions.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.definitions.nodes.map(buildPreciseLocation)),
|
||||
},
|
||||
},
|
||||
}))
|
||||
},
|
||||
})
|
||||
}))
|
||||
},
|
||||
}
|
||||
)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { ErrorLike } from '@sourcegraph/common'
|
||||
|
||||
import type { ConnectionQueryArguments } from '../components/FilteredConnection'
|
||||
import type { UsePreciseCodeIntelForPositionVariables } from '../graphql-operations'
|
||||
|
||||
import type { LocationsGroup } from './location'
|
||||
@ -44,7 +43,7 @@ export interface UseCodeIntelResult {
|
||||
}
|
||||
|
||||
export interface UseCodeIntelParameters {
|
||||
variables: UsePreciseCodeIntelForPositionVariables & ConnectionQueryArguments
|
||||
variables: UsePreciseCodeIntelForPositionVariables
|
||||
|
||||
searchToken: string
|
||||
fileContent: string
|
||||
|
||||
@ -94,25 +94,6 @@ interface ConnectionNodesProps<C extends Connection<N>, N, NP = {}, HP = {}>
|
||||
onShowMore: () => void
|
||||
}
|
||||
|
||||
export const getTotalCount = <N,>({ totalCount, nodes, pageInfo }: Connection<N>, first: number): number | null => {
|
||||
if (typeof totalCount === 'number') {
|
||||
return totalCount
|
||||
}
|
||||
|
||||
if (
|
||||
// TODO(sqs): this line below is wrong because `first` might've just been changed and
|
||||
// `nodes` is still the data fetched from before `first` was changed.
|
||||
// this causes the UI to incorrectly show "N items total" even when the count is indeterminate right
|
||||
// after the user clicks "Show more" but before the new data is loaded.
|
||||
nodes.length < first ||
|
||||
(nodes.length === first && pageInfo && typeof pageInfo.hasNextPage === 'boolean' && !pageInfo.hasNextPage)
|
||||
) {
|
||||
return nodes.length
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const ConnectionNodes = <C extends Connection<N>, N, NP = {}, HP = {}>({
|
||||
nodeComponent: NodeComponent,
|
||||
nodeComponentProps,
|
||||
@ -126,7 +107,6 @@ export const ConnectionNodes = <C extends Connection<N>, N, NP = {}, HP = {}>({
|
||||
emptyElement,
|
||||
totalCountSummaryComponent,
|
||||
connection,
|
||||
first,
|
||||
noSummaryIfAllNodesVisible,
|
||||
noun,
|
||||
pluralNoun,
|
||||
@ -142,7 +122,6 @@ export const ConnectionNodes = <C extends Connection<N>, N, NP = {}, HP = {}>({
|
||||
|
||||
const summary = (
|
||||
<ConnectionSummary
|
||||
first={first}
|
||||
noSummaryIfAllNodesVisible={noSummaryIfAllNodesVisible}
|
||||
totalCountSummaryComponent={totalCountSummaryComponent}
|
||||
noun={noun}
|
||||
|
||||
@ -1,12 +1,3 @@
|
||||
/**
|
||||
* The arguments for the connection query
|
||||
*/
|
||||
export interface ConnectionQueryArguments {
|
||||
first?: number
|
||||
after?: string | null
|
||||
query?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* See https://facebook.github.io/relay/graphql/connections.htm.
|
||||
*/
|
||||
|
||||
@ -6,15 +6,14 @@ import { RadioButtons } from '../RadioButtons'
|
||||
|
||||
import styles from './FilterControl.module.scss'
|
||||
|
||||
export type BasicFilterArgs = Record<string, string | number | boolean | null | undefined>
|
||||
|
||||
/**
|
||||
* A filter to display next to the search input field.
|
||||
* @template K The IDs of all filters ({@link Filter.id} values).
|
||||
* @template A The type of option args ({@link Filter.options} {@link FilterOption.args} values).
|
||||
* @template TKey The IDs of all filters ({@link Filter.id} values).
|
||||
* @template TArg The type of option args ({@link Filter.options} {@link FilterOption.args} values).
|
||||
*/
|
||||
export interface Filter<
|
||||
K extends string = string,
|
||||
A extends Record<string, string | number | boolean | null> = Record<string, string | number | boolean | null>
|
||||
> {
|
||||
export interface Filter<TKey extends string = string, TArg extends BasicFilterArgs = BasicFilterArgs> {
|
||||
/** The UI label for the filter. */
|
||||
label: string
|
||||
|
||||
@ -25,7 +24,7 @@ export interface Filter<
|
||||
* The URL query parameter name for this filter (conventionally the label, lowercased and
|
||||
* without spaces and punctuation).
|
||||
*/
|
||||
id: K
|
||||
id: TKey
|
||||
|
||||
/** An optional tooltip to display for this filter. */
|
||||
tooltip?: string
|
||||
@ -33,16 +32,14 @@ export interface Filter<
|
||||
/**
|
||||
* All of the possible values for this filter that the user can select.
|
||||
*/
|
||||
options: FilterOption<A>[]
|
||||
options: FilterOption<TArg>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* An option that the user can select for a filter ({@link Filter}).
|
||||
* @template A The type of option args ({@link Filter.options} {@link FilterOption.args} values).
|
||||
* @template TArg The type of option args ({@link Filter.options} {@link FilterOption.args} values).
|
||||
*/
|
||||
export interface FilterOption<
|
||||
A extends Record<string, string | number | boolean | null> = Record<string, string | number | boolean | null>
|
||||
> {
|
||||
export interface FilterOption<TArg extends BasicFilterArgs = BasicFilterArgs> {
|
||||
/**
|
||||
* The value (corresponding to the key in {@link Filter.id}) if this option is chosen. For
|
||||
* example, if a filter has {@link Filter.id} of `sort` and the user selects a
|
||||
@ -52,33 +49,31 @@ export interface FilterOption<
|
||||
value: string
|
||||
label: string
|
||||
tooltip?: string
|
||||
args: A
|
||||
args: TArg
|
||||
}
|
||||
|
||||
/**
|
||||
* The values of all filters, keyed by the filter ID ({@link Filter.id}).
|
||||
* @template K The IDs of all filters ({@link Filter.id} values).
|
||||
*/
|
||||
export type FilterValues<K extends string = string> = Record<K, FilterOption['value'] | null>
|
||||
export type FilterValues<TKey extends string = string> = Partial<Record<TKey, FilterOption['value']>>
|
||||
|
||||
interface FilterControlProps {
|
||||
/** All filters. */
|
||||
filters: Filter[]
|
||||
|
||||
/** Called when a filter is selected. */
|
||||
onValueSelect: (filter: Filter, value: FilterOption['value']) => void
|
||||
|
||||
values: FilterValues
|
||||
}
|
||||
|
||||
export const FilterControl: React.FunctionComponent<React.PropsWithChildren<FilterControlProps>> = ({
|
||||
export function FilterControl<TKey extends string = string>({
|
||||
filters,
|
||||
values,
|
||||
onValueSelect,
|
||||
children,
|
||||
}) => {
|
||||
}: React.PropsWithChildren<{
|
||||
/** All filters. */
|
||||
filters: Filter<TKey>[]
|
||||
|
||||
/** Called when a filter is selected. */
|
||||
onValueSelect: (filter: Filter<TKey>, value: FilterOption['value']) => void
|
||||
|
||||
values: FilterValues<TKey>
|
||||
}>): JSX.Element {
|
||||
const onChange = useCallback(
|
||||
(filter: Filter, id: string) => {
|
||||
(filter: Filter<TKey>, id: string) => {
|
||||
const value = filter.options.find(opt => opt.value === id)
|
||||
if (value === undefined) {
|
||||
return
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor, act } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type * as H from 'history'
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
@ -80,10 +80,10 @@ describe('FilteredConnection', () => {
|
||||
})
|
||||
|
||||
// Click "Show more" button, should cause history to be updated
|
||||
fireEvent.click(screen.getByRole('button')!)
|
||||
expect(currentLocation!.search).toEqual('?foo=bar&first=40')
|
||||
fireEvent.click(screen.getByRole('button')!)
|
||||
expect(currentLocation!.search).toEqual('?foo=bar&first=80')
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(currentLocation!.search).toEqual('?first=40&foo=bar')
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(currentLocation!.search).toEqual('?first=80&foo=bar')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -154,7 +154,7 @@ describe('ConnectionNodes', () => {
|
||||
onShowMore={showMoreCallback}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button')!)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
await waitFor(() => sinon.assert.calledOnce(showMoreCallback))
|
||||
})
|
||||
|
||||
@ -183,7 +183,7 @@ describe('ConnectionNodes', () => {
|
||||
|
||||
// Summary should come after the nodes.
|
||||
expect(
|
||||
screen.getByTestId('summary')!.compareDocumentPosition(screen.getByTestId('filtered-connection-nodes'))
|
||||
screen.getByTestId('summary').compareDocumentPosition(screen.getByTestId('filtered-connection-nodes'))
|
||||
).toEqual(Node.DOCUMENT_POSITION_PRECEDING)
|
||||
})
|
||||
|
||||
@ -221,7 +221,7 @@ describe('ConnectionNodes', () => {
|
||||
)
|
||||
// Summary should come _before_ the nodes.
|
||||
expect(
|
||||
screen.getByTestId('summary')!.compareDocumentPosition(screen.getByTestId('filtered-connection-nodes'))
|
||||
screen.getByTestId('summary').compareDocumentPosition(screen.getByTestId('filtered-connection-nodes'))
|
||||
).toEqual(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
})
|
||||
|
||||
@ -236,7 +236,7 @@ describe('ConnectionNodes', () => {
|
||||
)
|
||||
// Summary should come _before_ the nodes.
|
||||
expect(
|
||||
screen.getByTestId('summary')!.compareDocumentPosition(screen.getByTestId('filtered-connection-nodes'))
|
||||
screen.getByTestId('summary').compareDocumentPosition(screen.getByTestId('filtered-connection-nodes'))
|
||||
).toEqual(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
})
|
||||
})
|
||||
|
||||
@ -28,12 +28,13 @@ import {
|
||||
type ConnectionNodesState,
|
||||
type ConnectionProps,
|
||||
} from './ConnectionNodes'
|
||||
import type { Connection, ConnectionQueryArguments } from './ConnectionType'
|
||||
import type { Connection } from './ConnectionType'
|
||||
import { QUERY_KEY } from './constants'
|
||||
import type { Filter, FilterOption, FilterValues } from './FilterControl'
|
||||
import type { BasicFilterArgs, Filter, FilterOption, FilterValues } from './FilterControl'
|
||||
import { DEFAULT_PAGE_SIZE } from './hooks/usePageSwitcherPagination'
|
||||
import { ConnectionContainer, ConnectionError, ConnectionForm, ConnectionLoading } from './ui'
|
||||
import type { ConnectionFormProps } from './ui/ConnectionForm'
|
||||
import { getFilterFromURL, getUrlQuery, hasID, parseQueryInt } from './utils'
|
||||
import { getFilterFromURL, hasID, parseQueryInt, urlSearchParamsForFilteredConnection } from './utils'
|
||||
|
||||
/**
|
||||
* Fields that belong in FilteredConnectionProps and that don't depend on the type parameters. These are the fields
|
||||
@ -113,11 +114,7 @@ interface FilteredConnectionProps<C extends Connection<N>, N, NP = {}, HP = {}>
|
||||
queryConnection: (args: FilteredConnectionQueryArguments) => Observable<C>
|
||||
|
||||
/** Called when the queryConnection Observable emits. */
|
||||
onUpdate?: (
|
||||
value: C | ErrorLike | undefined,
|
||||
query: string,
|
||||
activeValues: Record<string, string | number | boolean | null>
|
||||
) => void
|
||||
onUpdate?: (value: C | ErrorLike | undefined, query: string, activeValues: Partial<BasicFilterArgs>) => void
|
||||
|
||||
/**
|
||||
* Set to true when the GraphQL response is expected to emit an `PageInfo.endCursor` value when
|
||||
@ -131,7 +128,11 @@ interface FilteredConnectionProps<C extends Connection<N>, N, NP = {}, HP = {}>
|
||||
/**
|
||||
* The arguments for the Props.queryConnection function.
|
||||
*/
|
||||
export interface FilteredConnectionQueryArguments extends ConnectionQueryArguments {}
|
||||
export interface FilteredConnectionQueryArguments {
|
||||
query?: string
|
||||
first?: number | null
|
||||
after?: string | null
|
||||
}
|
||||
|
||||
interface FilteredConnectionState<C extends Connection<N>, N> extends ConnectionNodesState {
|
||||
activeFilterValues: FilterValues
|
||||
@ -183,7 +184,7 @@ class InnerFilteredConnection<N, NP = {}, HP = {}, C extends Connection<N> = Con
|
||||
FilteredConnectionState<C, N>
|
||||
> {
|
||||
public static defaultProps: Partial<FilteredConnectionProps<any, any>> = {
|
||||
defaultFirst: 20,
|
||||
defaultFirst: DEFAULT_PAGE_SIZE,
|
||||
useURLQuery: true,
|
||||
}
|
||||
|
||||
@ -384,17 +385,16 @@ class InnerFilteredConnection<N, NP = {}, HP = {}, C extends Connection<N> = Con
|
||||
({ connectionOrError, previousPage, ...rest }) => {
|
||||
if (this.props.useURLQuery) {
|
||||
const { location, navigate } = this.props
|
||||
const searchFragment = this.urlQuery({ visibleResultCount: previousPage.length })
|
||||
const searchFragmentParams = new URLSearchParams(searchFragment)
|
||||
searchFragmentParams.sort()
|
||||
const newParams = this.urlQuery({ first: previousPage.length })
|
||||
newParams.sort()
|
||||
|
||||
const oldParams = new URLSearchParams(location.search)
|
||||
oldParams.sort()
|
||||
|
||||
if (!isEqual(Array.from(searchFragmentParams), Array.from(oldParams))) {
|
||||
if (!isEqual(Array.from(newParams), Array.from(oldParams))) {
|
||||
navigate(
|
||||
{
|
||||
search: searchFragment,
|
||||
search: newParams.toString(),
|
||||
hash: location.hash,
|
||||
},
|
||||
{
|
||||
@ -510,13 +510,11 @@ class InnerFilteredConnection<N, NP = {}, HP = {}, C extends Connection<N> = Con
|
||||
first,
|
||||
query,
|
||||
filterValues,
|
||||
visibleResultCount,
|
||||
}: {
|
||||
first?: number
|
||||
query?: string
|
||||
filterValues?: FilterValues
|
||||
visibleResultCount?: number
|
||||
}): string {
|
||||
}): URLSearchParams {
|
||||
if (!first) {
|
||||
first = this.state.first
|
||||
}
|
||||
@ -527,15 +525,12 @@ class InnerFilteredConnection<N, NP = {}, HP = {}, C extends Connection<N> = Con
|
||||
filterValues = this.state.activeFilterValues
|
||||
}
|
||||
|
||||
return getUrlQuery({
|
||||
return urlSearchParamsForFilteredConnection({
|
||||
query,
|
||||
first: {
|
||||
actual: first,
|
||||
// Always set through `defaultProps`
|
||||
default: this.props.defaultFirst!,
|
||||
},
|
||||
pagination: { first },
|
||||
// Always set through `defaultProps`
|
||||
pageSize: this.props.defaultFirst!,
|
||||
filterValues,
|
||||
visibleResultCount,
|
||||
search: this.props.location.search,
|
||||
filters: this.props.filters,
|
||||
})
|
||||
@ -650,7 +645,7 @@ class InnerFilteredConnection<N, NP = {}, HP = {}, C extends Connection<N> = Con
|
||||
this.queryInputChanges.next(event.currentTarget.value)
|
||||
}
|
||||
|
||||
private onDidSelectFilterValue = (filter: Filter, value: FilterOption['value'] | null): void => {
|
||||
private onDidSelectFilterValue = (filter: Filter, value: FilterOption['value'] | undefined): void => {
|
||||
if (this.props.filters === undefined) {
|
||||
return
|
||||
}
|
||||
@ -665,14 +660,14 @@ class InnerFilteredConnection<N, NP = {}, HP = {}, C extends Connection<N> = Con
|
||||
}
|
||||
|
||||
/**
|
||||
* @template K The IDs of all filters ({@link Filter.id} values).
|
||||
* @template A The type of option args ({@link Filter.options} {@link FilterOption.args} values).
|
||||
* @template TFilterKeys The IDs of all filters ({@link Filter.id} values).
|
||||
* @template TFilterArgs The type of option args ({@link Filter.options} {@link FilterOption.args} values).
|
||||
*/
|
||||
export function buildFilterArgs<
|
||||
K extends string = string,
|
||||
A extends Record<string, string | number | boolean | null> = Record<string, string | number | boolean | null>
|
||||
>(filters: Filter<K, A>[], filterValues: FilterValues<K>): A {
|
||||
let args = {} as unknown as A
|
||||
TFilterKeys extends string = string,
|
||||
TFilterArgs extends BasicFilterArgs = BasicFilterArgs
|
||||
>(filters: Filter<TFilterKeys, TFilterArgs>[], filterValues: FilterValues<TFilterKeys>): Partial<TFilterArgs> {
|
||||
let args = {} as TFilterArgs
|
||||
for (const [filterID, value] of Object.entries(filterValues)) {
|
||||
if (value === undefined) {
|
||||
continue
|
||||
|
||||
@ -0,0 +1,156 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { QUERY_KEY } from '../constants'
|
||||
import type { Filter } from '../FilterControl'
|
||||
import { getFilterFromURL, parseQueryInt, urlSearchParamsForFilteredConnection } from '../utils'
|
||||
|
||||
import type { PaginatedConnectionQueryArguments } from './usePageSwitcherPagination'
|
||||
|
||||
/**
|
||||
* The value and a setter for the value of a GraphQL connection's params.
|
||||
*/
|
||||
export type UseConnectionStateResult<TState extends PaginatedConnectionQueryArguments> = [
|
||||
connectionState: TState,
|
||||
|
||||
/**
|
||||
* Set the {@link UseConnectionStateResult.connectionState} value in a callback that receives the current
|
||||
* value as an argument. Usually callers to {@link UseConnectionStateResult.setConnectionState} will
|
||||
* want to merge values (like `updateValue(prev => ({...prev, ...newValue}))`).
|
||||
*/
|
||||
setConnectionState: (valueFunc: (current: TState) => TState) => void
|
||||
]
|
||||
|
||||
/**
|
||||
* A React hook for using the URL querystring to store the state of a paginated connection,
|
||||
* including both pagination parameters (such as `first` and `after`) and other custom filter
|
||||
* parameters.
|
||||
*/
|
||||
export function useUrlSearchParamsForConnectionState<TFilterKeys extends string>(
|
||||
filters?: Filter<TFilterKeys>[]
|
||||
): UseConnectionStateResult<
|
||||
Partial<Record<TFilterKeys, string>> & { query?: string } & PaginatedConnectionQueryArguments
|
||||
> {
|
||||
type TState = Partial<Record<TFilterKeys, string>> & { query?: string } & PaginatedConnectionQueryArguments
|
||||
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Use a ref that is set on each render so that our `setValue` callback can access the latest
|
||||
// value without having the value as one of its deps, which can cause render cycles. Note that
|
||||
// this is how `useState` works as well (the setter's function value does not change when the
|
||||
// value changes).
|
||||
const value = useRef<TState>()
|
||||
value.current = useMemo<TState>((): TState => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
|
||||
const pgParams: PaginatedConnectionQueryArguments = {
|
||||
first: parseQueryInt(params, 'first'),
|
||||
last: parseQueryInt(params, 'last'),
|
||||
after: params.get('after') ?? undefined,
|
||||
before: params.get('before') ?? undefined,
|
||||
}
|
||||
const filterParams: Partial<Record<TFilterKeys, string>> = filters
|
||||
? getFilterFromURL<TFilterKeys>(params, filters)
|
||||
: {}
|
||||
return {
|
||||
query: params.get(QUERY_KEY) ?? '',
|
||||
...pgParams,
|
||||
...filterParams,
|
||||
}
|
||||
}, [location.search, filters])
|
||||
|
||||
const locationRef = useRef<typeof location>(location)
|
||||
locationRef.current = location
|
||||
const setValue = useCallback(
|
||||
(valueFunc: (current: TState) => TState) => {
|
||||
const location = locationRef.current
|
||||
const newValue = valueFunc(value.current!)
|
||||
const params = urlSearchParamsForFilteredConnection({
|
||||
pagination: {
|
||||
first: newValue.first,
|
||||
last: newValue.last,
|
||||
after: newValue.after,
|
||||
before: newValue.before,
|
||||
},
|
||||
filters,
|
||||
filterValues: newValue,
|
||||
query: 'query' in newValue ? newValue.query : '',
|
||||
search: location.search,
|
||||
})
|
||||
navigate(
|
||||
{
|
||||
search: params.toString(),
|
||||
hash: location.hash,
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
state: location.state, // Preserve flash messages.
|
||||
}
|
||||
)
|
||||
},
|
||||
[filters, navigate]
|
||||
)
|
||||
|
||||
return [value.current, setValue]
|
||||
}
|
||||
|
||||
/**
|
||||
* A React hook for using the provided connection state (usually from
|
||||
* {@link useUrlSearchParamsForConnectionState}) if defined, or otherwise falling back to an
|
||||
* in-memory connection state implementation that does not read from and write to the URL.
|
||||
*/
|
||||
export function useConnectionStateOrMemoryFallback<
|
||||
TFilterKeys extends string,
|
||||
TState extends PaginatedConnectionQueryArguments = Record<TFilterKeys | 'query', string> &
|
||||
PaginatedConnectionQueryArguments
|
||||
>(state: UseConnectionStateResult<TState> | undefined): UseConnectionStateResult<TState> {
|
||||
const memoryState = useState<TState>({} as TState)
|
||||
return state ?? memoryState
|
||||
}
|
||||
|
||||
/**
|
||||
* A React hook that wraps the provided {@link UseConnectionStateResult} so that `?first` and
|
||||
* `?last` URL parameters are omitted if they are equal to the default page size. This makes the
|
||||
* URLs look nicer.
|
||||
*/
|
||||
export function useConnectionStateWithImplicitPageSize<
|
||||
TFilterKeys extends string,
|
||||
TState extends PaginatedConnectionQueryArguments = Record<TFilterKeys | 'query', string> &
|
||||
PaginatedConnectionQueryArguments
|
||||
>(state: UseConnectionStateResult<TState>, pageSize: number): UseConnectionStateResult<TState> {
|
||||
const [value, setValue] = state
|
||||
|
||||
// The resolved value has explicit `first` and `last`.
|
||||
const resolvedValue = useMemo<TState>(
|
||||
() => ({
|
||||
...value,
|
||||
first: value.first ?? (!value.before && !value.last ? pageSize : null),
|
||||
last: value.last ?? (value.before && !value.after && !value.first ? pageSize : null),
|
||||
}),
|
||||
[value, pageSize]
|
||||
)
|
||||
|
||||
// The setter removes `first` and `last` if they are equal to the default page size and
|
||||
// otherwise implicit.
|
||||
const setValueWithImplicits = useCallback(
|
||||
(valueFunc: (current: TState) => TState) => {
|
||||
setValue(prev => {
|
||||
const newValue = valueFunc(prev)
|
||||
return {
|
||||
...newValue,
|
||||
first:
|
||||
newValue.first === pageSize && !newValue.before && !newValue.last ? undefined : newValue.first,
|
||||
last:
|
||||
newValue.last === pageSize && newValue.before && !newValue.after && !newValue.first
|
||||
? undefined
|
||||
: newValue.last,
|
||||
}
|
||||
})
|
||||
},
|
||||
[pageSize, setValue]
|
||||
)
|
||||
|
||||
return [resolvedValue, setValueWithImplicits]
|
||||
}
|
||||
@ -5,13 +5,14 @@ import { describe, expect, it } from 'vitest'
|
||||
import { dataOrThrowErrors, getDocumentNode } from '@sourcegraph/http-client'
|
||||
import { MockedTestProvider, waitForNextApolloResponse } from '@sourcegraph/shared/src/testing/apollo'
|
||||
import { Text } from '@sourcegraph/wildcard'
|
||||
import { type RenderWithBrandedContextResult, renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
|
||||
import { renderWithBrandedContext, type RenderWithBrandedContextResult } from '@sourcegraph/wildcard/src/testing'
|
||||
|
||||
import { usePageSwitcherPagination } from './usePageSwitcherPagination'
|
||||
import { useUrlSearchParamsForConnectionState } from './connectionState'
|
||||
import { usePageSwitcherPagination, type PaginatedConnectionQueryArguments } from './usePageSwitcherPagination'
|
||||
|
||||
type TestPageSwitcherPaginationQueryFields = any
|
||||
type TestPageSwitcherPaginationQueryResult = any
|
||||
type TestPageSwitcherPaginationQueryVariables = any
|
||||
interface TestPageSwitcherPaginationQueryVariables extends PaginatedConnectionQueryArguments {}
|
||||
const TEST_PAGINATED_CONNECTION_QUERY = `
|
||||
query TestPageSwitcherPaginationQuery($first: Int, $last: Int, $after: String, $before: String) {
|
||||
savedSearchesByNamespace(
|
||||
@ -43,7 +44,8 @@ const TEST_PAGINATED_CONNECTION_QUERY = `
|
||||
|
||||
const PAGE_SIZE = 3
|
||||
|
||||
const TestComponent = ({ useURL }: { useURL: boolean }) => {
|
||||
const TestComponent = () => {
|
||||
const connectionState = useUrlSearchParamsForConnectionState([])
|
||||
const { connection, loading, goToNextPage, goToPreviousPage, goToFirstPage, goToLastPage } =
|
||||
usePageSwitcherPagination<
|
||||
TestPageSwitcherPaginationQueryResult,
|
||||
@ -57,9 +59,9 @@ const TestComponent = ({ useURL }: { useURL: boolean }) => {
|
||||
return data.savedSearchesByNamespace
|
||||
},
|
||||
options: {
|
||||
useURL,
|
||||
pageSize: PAGE_SIZE,
|
||||
},
|
||||
state: connectionState,
|
||||
})
|
||||
|
||||
return (
|
||||
@ -220,12 +222,11 @@ const generateMockCursorResponsesForEveryPage = (
|
||||
describe('usePageSwitcherPagination', () => {
|
||||
const renderWithMocks = async (
|
||||
mocks: MockedResponse<TestPageSwitcherPaginationQueryResult>[],
|
||||
useURL: boolean = true,
|
||||
initialRoute = '/'
|
||||
) => {
|
||||
const renderResult = renderWithBrandedContext(
|
||||
<MockedTestProvider mocks={mocks}>
|
||||
<TestComponent useURL={useURL} />
|
||||
<TestComponent />
|
||||
</MockedTestProvider>,
|
||||
{ route: initialRoute }
|
||||
)
|
||||
@ -303,7 +304,7 @@ describe('usePageSwitcherPagination', () => {
|
||||
})
|
||||
|
||||
it('supports restoration from forward pagination URL', async () => {
|
||||
const page = await renderWithMocks(cursorMocks, true, `/?after=${getCursorForId('6')}`)
|
||||
const page = await renderWithMocks(cursorMocks, `/?after=${getCursorForId('6')}`)
|
||||
|
||||
expect(page.getAllByRole('listitem').length).toBe(3)
|
||||
expect(page.getAllByRole('listitem')[0]).toHaveTextContent('result 7')
|
||||
@ -318,7 +319,7 @@ describe('usePageSwitcherPagination', () => {
|
||||
})
|
||||
|
||||
it('supports jumping to the first page', async () => {
|
||||
const page = await renderWithMocks(cursorMocks, true, '/?last=3')
|
||||
const page = await renderWithMocks(cursorMocks, '/?last=3')
|
||||
|
||||
await goToFirstPage(page)
|
||||
|
||||
@ -356,7 +357,7 @@ describe('usePageSwitcherPagination', () => {
|
||||
})
|
||||
|
||||
it('supports restoration from last page URL', async () => {
|
||||
const page = await renderWithMocks(cursorMocks, true, '/?last=3')
|
||||
const page = await renderWithMocks(cursorMocks, '/?last=3')
|
||||
|
||||
expect(page.getAllByRole('listitem').length).toBe(3)
|
||||
expect(page.getAllByRole('listitem')[0]).toHaveTextContent('result 8')
|
||||
@ -419,7 +420,7 @@ describe('usePageSwitcherPagination', () => {
|
||||
})
|
||||
|
||||
it('supports restoration from backward pagination URL', async () => {
|
||||
const page = await renderWithMocks(cursorMocks, true, `?before=${getCursorForId('5')}`)
|
||||
const page = await renderWithMocks(cursorMocks, `?before=${getCursorForId('5')}`)
|
||||
|
||||
expect(page.getAllByRole('listitem').length).toBe(3)
|
||||
expect(page.getAllByRole('listitem')[0]).toHaveTextContent('result 2')
|
||||
@ -432,23 +433,4 @@ describe('usePageSwitcherPagination', () => {
|
||||
expect(page.getByText('Next page')).toBeVisible()
|
||||
expect(page.getByText('Last page')).toBeVisible()
|
||||
})
|
||||
|
||||
it('does not change the URL when useURL is disabled', async () => {
|
||||
const page = await renderWithMocks(cursorMocks, false, `/?after=${getCursorForId('6')}`)
|
||||
|
||||
expect(page.getAllByRole('listitem').length).toBe(3)
|
||||
expect(page.getAllByRole('listitem')[0]).toHaveTextContent('result 1')
|
||||
expect(page.getAllByRole('listitem')[1]).toHaveTextContent('result 2')
|
||||
expect(page.getAllByRole('listitem')[2]).toHaveTextContent('result 3')
|
||||
expect(page.getByText('Total count: 10')).toBeVisible()
|
||||
|
||||
expect(page.getByText('First page')).toBeVisible()
|
||||
expect(() => page.getByText('Previous page')).toThrowError(/Unable to find an element/)
|
||||
expect(page.getByText('Next page')).toBeVisible()
|
||||
expect(page.getByText('Last page')).toBeVisible()
|
||||
|
||||
await goToLastPage(page)
|
||||
|
||||
expect(page.locationRef.current?.search).toBe(`?after=${getCursorForId('6')}`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import type { ApolloError, WatchQueryFetchPolicy } from '@apollo/client'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useQuery, type GraphQLResult } from '@sourcegraph/http-client'
|
||||
|
||||
import { asGraphQLResult } from '../utils'
|
||||
|
||||
import {
|
||||
useConnectionStateOrMemoryFallback,
|
||||
useConnectionStateWithImplicitPageSize,
|
||||
type UseConnectionStateResult,
|
||||
} from './connectionState'
|
||||
|
||||
export interface PaginatedConnectionQueryArguments {
|
||||
first?: number | null
|
||||
last?: number | null
|
||||
@ -47,28 +52,40 @@ export interface UsePaginatedConnectionResult<TResult, TVariables, TNode> extend
|
||||
}
|
||||
|
||||
interface UsePaginatedConnectionConfig<TResult> {
|
||||
// The number of items per page, defaults to 20
|
||||
/** The number of items per page. Defaults to 20. */
|
||||
pageSize?: number
|
||||
// Set if query variables should be updated in and derived from the URL
|
||||
useURL?: boolean
|
||||
// Allows modifying how the query interacts with the Apollo cache
|
||||
|
||||
/** Allows modifying how the query interacts with the Apollo cache. */
|
||||
fetchPolicy?: WatchQueryFetchPolicy
|
||||
// Allows running an optional callback on any successful request
|
||||
|
||||
/** Allows running an optional callback on any successful request. */
|
||||
onCompleted?: (data: TResult) => void
|
||||
// Allows to provide polling interval to useQuery
|
||||
|
||||
/** Allows to provide polling interval to useQuery. */
|
||||
pollInterval?: number
|
||||
}
|
||||
|
||||
export type PaginationKeys = 'first' | 'last' | 'before' | 'after'
|
||||
|
||||
interface UsePaginatedConnectionParameters<TResult, TVariables extends PaginatedConnectionQueryArguments, TNode> {
|
||||
interface UsePaginatedConnectionParameters<
|
||||
TResult,
|
||||
TVariables extends PaginatedConnectionQueryArguments,
|
||||
TNode,
|
||||
TState extends PaginatedConnectionQueryArguments
|
||||
> {
|
||||
query: string
|
||||
variables: Omit<TVariables, PaginationKeys>
|
||||
getConnection: (result: GraphQLResult<TResult>) => PaginatedConnection<TNode> | undefined
|
||||
options?: UsePaginatedConnectionConfig<TResult>
|
||||
|
||||
/**
|
||||
* The value and setter for the state parameters (such as `first`, `after`, `before`, and
|
||||
* filters).
|
||||
*/
|
||||
state?: UseConnectionStateResult<TState>
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
export const DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
/**
|
||||
* Request a GraphQL connection query and handle pagination options.
|
||||
@ -78,26 +95,38 @@ const DEFAULT_PAGE_SIZE = 20
|
||||
* @param getConnection A function that filters and returns the relevant data from the connection response.
|
||||
* @param options Additional configuration options
|
||||
*/
|
||||
export const usePageSwitcherPagination = <TResult, TVariables extends PaginatedConnectionQueryArguments, TNode>({
|
||||
export const usePageSwitcherPagination = <
|
||||
TResult,
|
||||
TVariables extends PaginatedConnectionQueryArguments,
|
||||
TNode,
|
||||
TState extends PaginatedConnectionQueryArguments = PaginatedConnectionQueryArguments &
|
||||
Partial<Record<string | 'query', string>>
|
||||
>({
|
||||
query,
|
||||
variables,
|
||||
getConnection,
|
||||
options,
|
||||
}: UsePaginatedConnectionParameters<TResult, TVariables, TNode>): UsePaginatedConnectionResult<
|
||||
state,
|
||||
}: UsePaginatedConnectionParameters<TResult, TVariables, TNode, TState>): UsePaginatedConnectionResult<
|
||||
TResult,
|
||||
TVariables,
|
||||
TNode
|
||||
> => {
|
||||
const pageSize = options?.pageSize ?? DEFAULT_PAGE_SIZE
|
||||
const [initialPaginationArgs, setPaginationArgs] = useSyncPaginationArgsWithUrl(!!options?.useURL, pageSize)
|
||||
const [connectionState, setConnectionState] = useConnectionStateWithImplicitPageSize(
|
||||
useConnectionStateOrMemoryFallback(state),
|
||||
pageSize
|
||||
)
|
||||
|
||||
// TODO(philipp-spiess): Find out why Omit<TVariables, "first" | ...> & { first: number, ... }
|
||||
// does not work here and get rid of the any cast.
|
||||
|
||||
const queryVariables: TVariables = {
|
||||
const queryVariables = {
|
||||
...variables,
|
||||
...initialPaginationArgs,
|
||||
} as any
|
||||
|
||||
// Pagination
|
||||
first: connectionState.first ?? null,
|
||||
last: connectionState.last ?? null,
|
||||
after: connectionState.after ?? null,
|
||||
before: connectionState.before ?? null,
|
||||
} as TVariables
|
||||
|
||||
const {
|
||||
data: currentData,
|
||||
@ -126,10 +155,10 @@ export const usePageSwitcherPagination = <TResult, TVariables extends PaginatedC
|
||||
|
||||
const updatePagination = useCallback(
|
||||
async (nextPageArgs: PaginatedConnectionQueryArguments): Promise<void> => {
|
||||
setPaginationArgs(nextPageArgs)
|
||||
setConnectionState(prev => ({ ...prev, ...nextPageArgs }))
|
||||
await refetch(nextPageArgs as Partial<TVariables>)
|
||||
},
|
||||
[refetch, setPaginationArgs]
|
||||
[refetch, setConnectionState]
|
||||
)
|
||||
|
||||
const goToNextPage = useCallback(async (): Promise<void> => {
|
||||
@ -184,73 +213,3 @@ export const usePageSwitcherPagination = <TResult, TVariables extends PaginatedC
|
||||
stopPolling,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(philipp-spiess): We should make these callbacks overridable by the
|
||||
// consumer of this API to allow for serialization of other query parameters in
|
||||
// the URL (e.g. filters).
|
||||
//
|
||||
// We also need to change this if we ever want to allow users to change the page
|
||||
// size and want to make it persist in the URL.
|
||||
const getPaginationArgsFromSearch = (search: string, pageSize: number): PaginatedConnectionQueryArguments => {
|
||||
const searchParameters = new URLSearchParams(search)
|
||||
|
||||
if (searchParameters.has('after')) {
|
||||
return { first: pageSize, last: null, after: searchParameters.get('after'), before: null }
|
||||
}
|
||||
if (searchParameters.has('before')) {
|
||||
return { first: null, last: pageSize, after: null, before: searchParameters.get('before') }
|
||||
}
|
||||
// Special case for handling the last page.
|
||||
if (searchParameters.has('last')) {
|
||||
return { first: null, last: pageSize, after: null, before: null }
|
||||
}
|
||||
return { first: pageSize, last: null, after: null, before: null }
|
||||
}
|
||||
const getSearchFromPaginationArgs = (paginationArgs: PaginatedConnectionQueryArguments): string => {
|
||||
const searchParameters = new URLSearchParams()
|
||||
if (paginationArgs.after) {
|
||||
searchParameters.set('after', paginationArgs.after)
|
||||
return searchParameters.toString()
|
||||
}
|
||||
if (paginationArgs.before) {
|
||||
searchParameters.set('before', paginationArgs.before)
|
||||
return searchParameters.toString()
|
||||
}
|
||||
if (paginationArgs.last) {
|
||||
searchParameters.set('last', paginationArgs.last.toString())
|
||||
return searchParameters.toString()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const useSyncPaginationArgsWithUrl = (
|
||||
enabled: boolean,
|
||||
pageSize: number
|
||||
): [
|
||||
initialPaginationArgs: PaginatedConnectionQueryArguments,
|
||||
setPaginationArgs: (args: PaginatedConnectionQueryArguments) => void
|
||||
] => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const initialPaginationArgs = useMemo(() => {
|
||||
if (enabled) {
|
||||
return getPaginationArgsFromSearch(location.search, pageSize)
|
||||
}
|
||||
return { first: pageSize, last: null, after: null, before: null }
|
||||
// We deliberately ignore changes to the URL after the first render
|
||||
// since we assume that these are caused by this hook.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled])
|
||||
|
||||
const setPaginationArgs = useCallback(
|
||||
(paginationArgs: PaginatedConnectionQueryArguments): void => {
|
||||
if (enabled) {
|
||||
const search = getSearchFromPaginationArgs(paginationArgs)
|
||||
navigate({ search }, { replace: true })
|
||||
}
|
||||
},
|
||||
[enabled, navigate]
|
||||
)
|
||||
return [initialPaginationArgs, setPaginationArgs]
|
||||
}
|
||||
|
||||
@ -5,14 +5,16 @@ import { describe, expect, it } from 'vitest'
|
||||
import { dataOrThrowErrors, getDocumentNode, gql } from '@sourcegraph/http-client'
|
||||
import { MockedTestProvider, waitForNextApolloResponse } from '@sourcegraph/shared/src/testing/apollo'
|
||||
import { Text } from '@sourcegraph/wildcard'
|
||||
import { type RenderWithBrandedContextResult, renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
|
||||
import { renderWithBrandedContext, type RenderWithBrandedContextResult } from '@sourcegraph/wildcard/src/testing'
|
||||
|
||||
import type {
|
||||
TestShowMorePaginationQueryFields,
|
||||
TestShowMorePaginationQueryResult,
|
||||
TestShowMorePaginationQueryVariables,
|
||||
} from '../../../graphql-operations'
|
||||
import type { Filter } from '../FilterControl'
|
||||
|
||||
import { useUrlSearchParamsForConnectionState } from './connectionState'
|
||||
import { useShowMorePagination } from './useShowMorePagination'
|
||||
|
||||
const TEST_SHOW_MORE_PAGINATION_QUERY = gql`
|
||||
@ -36,24 +38,26 @@ const TEST_SHOW_MORE_PAGINATION_QUERY = gql`
|
||||
}
|
||||
`
|
||||
|
||||
const FILTERS: Filter[] = []
|
||||
|
||||
const TestComponent = ({ skip = false }) => {
|
||||
const connectionState = useUrlSearchParamsForConnectionState(FILTERS)
|
||||
const { connection, fetchMore, hasNextPage } = useShowMorePagination<
|
||||
TestShowMorePaginationQueryResult,
|
||||
TestShowMorePaginationQueryVariables,
|
||||
TestShowMorePaginationQueryFields
|
||||
>({
|
||||
query: TEST_SHOW_MORE_PAGINATION_QUERY,
|
||||
variables: {
|
||||
first: 1,
|
||||
},
|
||||
variables: {},
|
||||
getConnection: result => {
|
||||
const data = dataOrThrowErrors(result)
|
||||
return data.repositories
|
||||
},
|
||||
options: {
|
||||
useURL: true,
|
||||
skip,
|
||||
pageSize: 1,
|
||||
},
|
||||
state: connectionState,
|
||||
})
|
||||
|
||||
return (
|
||||
@ -208,7 +212,7 @@ describe('useShowMorePagination', () => {
|
||||
expect(queries.getByText('Fetch more')).toBeVisible()
|
||||
|
||||
// URL updates to match visible results
|
||||
expect(queries.locationRef.current?.search).toBe('?visible=2')
|
||||
expect(queries.locationRef.current?.search).toBe('?first=2')
|
||||
})
|
||||
|
||||
it('fetches final page of results correctly', async () => {
|
||||
@ -231,12 +235,12 @@ describe('useShowMorePagination', () => {
|
||||
expect(queries.queryByText('Fetch more')).not.toBeInTheDocument()
|
||||
|
||||
// URL updates to match visible results
|
||||
expect(queries.locationRef.current?.search).toBe('?visible=4')
|
||||
expect(queries.locationRef.current?.search).toBe('?first=4')
|
||||
})
|
||||
|
||||
it('fetches correct amount of results when navigating directly with a URL', async () => {
|
||||
// We need to add an extra mock here, as we will derive a different `first` variable from `visible` in the URL.
|
||||
const mockFromVisible: MockedResponse<TestShowMorePaginationQueryResult> = {
|
||||
// We need to add an extra mock here, as we will derive a different `first` variable the URL.
|
||||
const mockFromFirst: MockedResponse<TestShowMorePaginationQueryResult> = {
|
||||
request: generateMockRequest({ first: 3 }),
|
||||
result: generateMockResult({
|
||||
nodes: [mockResultNodes[0], mockResultNodes[1], mockResultNodes[2]],
|
||||
@ -246,7 +250,7 @@ describe('useShowMorePagination', () => {
|
||||
}),
|
||||
}
|
||||
|
||||
const queries = await renderWithMocks([...cursorMocks, mockFromVisible], '/?visible=3')
|
||||
const queries = await renderWithMocks([...cursorMocks, mockFromFirst], '/?first=3')
|
||||
|
||||
// Renders 3 results without having to manually fetch
|
||||
expect(queries.getAllByRole('listitem').length).toBe(3)
|
||||
@ -263,7 +267,7 @@ describe('useShowMorePagination', () => {
|
||||
expect(queries.getByText('Total count: 4')).toBeVisible()
|
||||
|
||||
// URL should be overidden
|
||||
expect(queries.locationRef.current?.search).toBe('?visible=4')
|
||||
expect(queries.locationRef.current?.search).toBe('?first=4')
|
||||
})
|
||||
})
|
||||
|
||||
@ -287,6 +291,15 @@ describe('useShowMorePagination', () => {
|
||||
totalCount: 4,
|
||||
}),
|
||||
},
|
||||
{
|
||||
request: generateMockRequest({ first: 3 }),
|
||||
result: generateMockResult({
|
||||
nodes: [mockResultNodes[0], mockResultNodes[1], mockResultNodes[2]],
|
||||
endCursor: null,
|
||||
hasNextPage: true,
|
||||
totalCount: 4,
|
||||
}),
|
||||
},
|
||||
{
|
||||
request: generateMockRequest({ first: 4 }),
|
||||
result: generateMockResult({
|
||||
@ -330,6 +343,7 @@ describe('useShowMorePagination', () => {
|
||||
// Fetch both pages
|
||||
await fetchNextPage(queries)
|
||||
await fetchNextPage(queries)
|
||||
await fetchNextPage(queries)
|
||||
|
||||
// All pages of results are displayed
|
||||
expect(queries.getAllByRole('listitem').length).toBe(4)
|
||||
@ -354,16 +368,16 @@ describe('useShowMorePagination', () => {
|
||||
expect(queries.getByText('repo-A')).toBeVisible()
|
||||
expect(queries.getByText('repo-B')).toBeVisible()
|
||||
expect(queries.getByText('Total count: 4')).toBeVisible()
|
||||
expect(queries.locationRef.current?.search).toBe('?first=2')
|
||||
|
||||
// Fetching next page should work as usual
|
||||
await fetchNextPage(queries)
|
||||
expect(queries.getAllByRole('listitem').length).toBe(4)
|
||||
expect(queries.getAllByRole('listitem').length).toBe(3)
|
||||
expect(queries.getByText('repo-C')).toBeVisible()
|
||||
expect(queries.getByText('repo-D')).toBeVisible()
|
||||
expect(queries.getByText('Total count: 4')).toBeVisible()
|
||||
|
||||
// URL should be overidden
|
||||
expect(queries.locationRef.current?.search).toBe('?first=4')
|
||||
expect(queries.locationRef.current?.search).toBe('?first=3')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,14 +1,24 @@
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
import type { ApolloError, QueryResult, WatchQueryFetchPolicy } from '@apollo/client'
|
||||
|
||||
import { type GraphQLResult, useQuery } from '@sourcegraph/http-client'
|
||||
import { useSearchParameters, useInterval } from '@sourcegraph/wildcard'
|
||||
import { useQuery, type GraphQLResult } from '@sourcegraph/http-client'
|
||||
import { useInterval } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { Connection, ConnectionQueryArguments } from '../ConnectionType'
|
||||
import { asGraphQLResult, hasNextPage, parseQueryInt } from '../utils'
|
||||
import type { Connection } from '../ConnectionType'
|
||||
import { asGraphQLResult, hasNextPage } from '../utils'
|
||||
|
||||
import { useShowMorePaginationUrl } from './useShowMorePaginationUrl'
|
||||
import {
|
||||
useConnectionStateOrMemoryFallback,
|
||||
useConnectionStateWithImplicitPageSize,
|
||||
type UseConnectionStateResult,
|
||||
} from './connectionState'
|
||||
import { DEFAULT_PAGE_SIZE } from './usePageSwitcherPagination'
|
||||
|
||||
export interface ShowMoreConnectionQueryArguments {
|
||||
first?: number | null
|
||||
after?: string | null
|
||||
}
|
||||
|
||||
export interface UseShowMorePaginationResult<TResult, TData> {
|
||||
data?: TResult
|
||||
@ -29,12 +39,15 @@ export interface UseShowMorePaginationResult<TResult, TData> {
|
||||
}
|
||||
|
||||
interface UseShowMorePaginationConfig<TResult> {
|
||||
/** Set if query variables should be updated in and derived from the URL */
|
||||
useURL?: boolean
|
||||
/** Allows modifying how the query interacts with the Apollo cache */
|
||||
/** The number of items per page. Defaults to 20. */
|
||||
pageSize?: number
|
||||
|
||||
/** Allows modifying how the query interacts with the Apollo cache. */
|
||||
fetchPolicy?: WatchQueryFetchPolicy
|
||||
/** Allows specifying the Apollo error policy */
|
||||
|
||||
/** Allows specifying the Apollo error policy. */
|
||||
errorPolicy?: 'all' | 'none' | 'ignore'
|
||||
|
||||
/**
|
||||
* Set to enable polling of all the nodes currently loaded in the connection.
|
||||
*
|
||||
@ -43,83 +56,96 @@ interface UseShowMorePaginationConfig<TResult> {
|
||||
* the data from polling responses when the two are in flight simultaneously.
|
||||
*/
|
||||
pollInterval?: number
|
||||
/** Allows running an optional callback on any successful request */
|
||||
|
||||
/** Allows running an optional callback on any successful request. */
|
||||
onCompleted?: (data: TResult) => void
|
||||
onError?: (error: ApolloError) => void
|
||||
|
||||
// useAlternateAfterCursor is used to indicate that a custom field instead of the
|
||||
// standard "after" field is used to for pagination. This is typically a
|
||||
// workaround for existing APIs where after may already be in use for
|
||||
// another field.
|
||||
/**
|
||||
* useAlternateAfterCursor is used to indicate that a custom field instead of the
|
||||
* standard "after" field is used to for pagination. This is typically a
|
||||
* workaround for existing APIs where after may already be in use for
|
||||
* another field.
|
||||
*/
|
||||
useAlternateAfterCursor?: boolean
|
||||
/** Skip the query if this condition is true */
|
||||
|
||||
/** Skip the query if this condition is true. */
|
||||
skip?: boolean
|
||||
}
|
||||
|
||||
interface UseShowMorePaginationParameters<TResult, TVariables, TData> {
|
||||
interface UseShowMorePaginationParameters<TResult, TVariables, TData, TState extends ShowMoreConnectionQueryArguments> {
|
||||
query: string
|
||||
variables: TVariables & ConnectionQueryArguments
|
||||
variables: Omit<TVariables, keyof ShowMoreConnectionQueryArguments | 'afterCursor'>
|
||||
getConnection: (result: GraphQLResult<TResult>) => Connection<TData>
|
||||
options?: UseShowMorePaginationConfig<TResult>
|
||||
|
||||
/**
|
||||
* The value and setter for the state parameters (such as `first`, `after`, `before`, and
|
||||
* filters).
|
||||
*/
|
||||
state?: UseConnectionStateResult<TState>
|
||||
}
|
||||
|
||||
const DEFAULT_AFTER: ConnectionQueryArguments['after'] = undefined
|
||||
const DEFAULT_FIRST: ConnectionQueryArguments['first'] = 20
|
||||
|
||||
/**
|
||||
* Request a GraphQL connection query and handle pagination options.
|
||||
* Valid queries should follow the connection specification at https://relay.dev/graphql/connections.htm
|
||||
* Request a GraphQL connection query and handle pagination options. When the user presses "show
|
||||
* more", all of the previous items still remain visible. This is for GraphQL connections that only
|
||||
* support fetching results in one direction (support for `first` is required, and support for
|
||||
* `after`/`endCursor` is optional) and/or where this "show more" behavior is desirable.
|
||||
*
|
||||
* For paginated behavior (where the user can press "next page" and see a different set of results),
|
||||
* and if the GraphQL connection supports full
|
||||
* `endCursor`/`startCursor`/`after`/`before`/`first`/`last`, use {@link usePageSwitcherPagination}
|
||||
* instead.
|
||||
*
|
||||
* Valid queries should follow the connection specification at
|
||||
* https://relay.dev/graphql/connections.htm.
|
||||
* @param query The GraphQL connection query
|
||||
* @param variables The GraphQL connection variables
|
||||
* @param getConnection A function that filters and returns the relevant data from the connection response.
|
||||
* @param getConnection A function that filters and returns the relevant data from the connection
|
||||
* response.
|
||||
* @param options Additional configuration options
|
||||
*/
|
||||
export const useShowMorePagination = <TResult, TVariables extends {}, TData>({
|
||||
export const useShowMorePagination = <
|
||||
TResult,
|
||||
TVariables extends ShowMoreConnectionQueryArguments,
|
||||
TData,
|
||||
TState extends ShowMoreConnectionQueryArguments = ShowMoreConnectionQueryArguments &
|
||||
Partial<Record<string | 'query', string>>
|
||||
>({
|
||||
query,
|
||||
variables,
|
||||
getConnection: getConnectionFromGraphQLResult,
|
||||
options,
|
||||
}: UseShowMorePaginationParameters<TResult, TVariables, TData>): UseShowMorePaginationResult<TResult, TData> => {
|
||||
const searchParameters = useSearchParameters()
|
||||
|
||||
const { first = DEFAULT_FIRST, after = DEFAULT_AFTER } = variables
|
||||
const firstReference = useRef({
|
||||
/**
|
||||
* The number of results that we will typically want to load in the next request (unless `visible` is used).
|
||||
* This value will typically be static for cursor-based pagination, but will be dynamic for batch-based pagination.
|
||||
*/
|
||||
actual: (options?.useURL && parseQueryInt(searchParameters, 'first')) || first,
|
||||
/**
|
||||
* Primarily used to determine original request state for URL search parameter logic.
|
||||
*/
|
||||
default: first,
|
||||
})
|
||||
|
||||
const initialControls = useMemo(
|
||||
() => ({
|
||||
/**
|
||||
* The `first` variable for our **initial** query.
|
||||
* If this is our first query and we were supplied a value for `visible` load that many results.
|
||||
* If we weren't given such a value or this is a subsequent request, only ask for one page of results.
|
||||
*
|
||||
* 'visible' is the number of results that were visible from previous requests. The initial request of
|
||||
* a result set will load `visible` items, then will request `first` items on each subsequent
|
||||
* request. This has the effect of loading the correct number of visible results when a URL
|
||||
* is copied during pagination. This value is only useful with cursor-based paging for the initial request.
|
||||
*/
|
||||
first: (options?.useURL && parseQueryInt(searchParameters, 'visible')) || firstReference.current.actual,
|
||||
/**
|
||||
* The `after` variable for our **initial** query.
|
||||
* Subsequent requests through `fetchMore` will use a valid `cursor` value here, where possible.
|
||||
*/
|
||||
after: (options?.useURL && searchParameters.get('after')) || after,
|
||||
}),
|
||||
// We only need these controls for the initial request. We do not care about dependency updates.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
state,
|
||||
}: UseShowMorePaginationParameters<TResult, TVariables, TData, TState>): UseShowMorePaginationResult<
|
||||
TResult,
|
||||
TData
|
||||
> => {
|
||||
const pageSize = options?.pageSize ?? DEFAULT_PAGE_SIZE
|
||||
const [connectionState, setConnectionState] = useConnectionStateWithImplicitPageSize(
|
||||
useConnectionStateOrMemoryFallback(state),
|
||||
pageSize
|
||||
)
|
||||
|
||||
const first = connectionState.first ?? pageSize
|
||||
|
||||
/**
|
||||
* Map over Apollo results to provide type-compatible `GraphQLResult`s for consumers.
|
||||
* This ensures good interoperability between `FilteredConnection` and `useShowMorePagination`.
|
||||
*/
|
||||
const getConnection = ({ data, error }: Pick<QueryResult<TResult>, 'data' | 'error'>): Connection<TData> => {
|
||||
const result = asGraphQLResult({ data, errors: error?.graphQLErrors || [] })
|
||||
return getConnectionFromGraphQLResult(result)
|
||||
}
|
||||
|
||||
// These will change when the user clicks "show more", but we want those fetches to go through
|
||||
// `fetchMore` and not through `useQuery` noticing that its variables have changed, so
|
||||
// use a ref to achieve that.
|
||||
const initialPaginationArgs = useRef({
|
||||
first,
|
||||
after: connectionState.after,
|
||||
})
|
||||
|
||||
/**
|
||||
* Initial query of the hook.
|
||||
* Subsequent requests (such as further pagination) will be handled through `fetchMore`
|
||||
@ -134,8 +160,8 @@ export const useShowMorePagination = <TResult, TVariables extends {}, TData>({
|
||||
} = useQuery<TResult, TVariables>(query, {
|
||||
variables: {
|
||||
...variables,
|
||||
...initialControls,
|
||||
},
|
||||
...initialPaginationArgs.current,
|
||||
} as TVariables,
|
||||
notifyOnNetworkStatusChange: true, // Ensures loading state is updated on `fetchMore`
|
||||
skip: options?.skip,
|
||||
fetchPolicy: options?.fetchPolicy,
|
||||
@ -144,28 +170,13 @@ export const useShowMorePagination = <TResult, TVariables extends {}, TData>({
|
||||
errorPolicy: options?.errorPolicy,
|
||||
})
|
||||
|
||||
/**
|
||||
* Map over Apollo results to provide type-compatible `GraphQLResult`s for consumers.
|
||||
* This ensures good interoperability between `FilteredConnection` and `useShowMorePagination`.
|
||||
*/
|
||||
const getConnection = ({ data, error }: Pick<QueryResult<TResult>, 'data' | 'error'>): Connection<TData> => {
|
||||
const result = asGraphQLResult({ data, errors: error?.graphQLErrors || [] })
|
||||
return getConnectionFromGraphQLResult(result)
|
||||
}
|
||||
|
||||
const data = currentData ?? previousData
|
||||
const connection = data ? getConnection({ data, error }) : undefined
|
||||
|
||||
useShowMorePaginationUrl({
|
||||
enabled: options?.useURL,
|
||||
first: firstReference.current,
|
||||
visibleResultCount: connection?.nodes.length,
|
||||
})
|
||||
|
||||
const fetchMoreData = async (): Promise<void> => {
|
||||
const cursor = connection?.pageInfo?.endCursor
|
||||
|
||||
// Use cursor paging if possible, otherwise fallback to multiplying `first`.
|
||||
// Use cursor paging if possible, otherwise fallback to increasing `first`.
|
||||
const afterVariables: { after?: string; first?: number; afterCursor?: string } = {}
|
||||
if (cursor) {
|
||||
if (options?.useAlternateAfterCursor) {
|
||||
@ -173,9 +184,15 @@ export const useShowMorePagination = <TResult, TVariables extends {}, TData>({
|
||||
} else {
|
||||
afterVariables.after = cursor
|
||||
}
|
||||
afterVariables.first = pageSize
|
||||
} else {
|
||||
afterVariables.first = firstReference.current.actual * 2
|
||||
afterVariables.first = first + pageSize
|
||||
}
|
||||
|
||||
// Don't reflect `after` in the URL because the page shows *all* items from the beginning,
|
||||
// not just those after the `after` cursor. The cursor is only used in the GraphQL request.
|
||||
setConnectionState(prev => ({ ...prev, first: first + pageSize }))
|
||||
|
||||
await fetchMore({
|
||||
variables: {
|
||||
...variables,
|
||||
@ -194,9 +211,9 @@ export const useShowMorePagination = <TResult, TVariables extends {}, TData>({
|
||||
const previousNodes = getConnection({ data: previousResult }).nodes
|
||||
getConnection({ data: fetchMoreResult }).nodes.unshift(...previousNodes)
|
||||
} else {
|
||||
// With batch-based pagination, we have all the results already in `fetchMoreResult`,
|
||||
// we just need to update `first` to fetch more results next time
|
||||
firstReference.current.actual *= 2
|
||||
// With batch-based pagination, we have all the results already in
|
||||
// `fetchMoreResult`. We already updated `first` via `setConnectionState` above
|
||||
// to fetch more results next time.
|
||||
}
|
||||
|
||||
return fetchMoreResult
|
||||
@ -206,22 +223,23 @@ export const useShowMorePagination = <TResult, TVariables extends {}, TData>({
|
||||
|
||||
// Refetch the current nodes
|
||||
const refetchAll = useCallback(async (): Promise<void> => {
|
||||
const first = connection?.nodes.length || firstReference.current.actual
|
||||
|
||||
// No change in connection state (`state.setValue`) needed.
|
||||
await refetch({
|
||||
...variables,
|
||||
first,
|
||||
})
|
||||
}, [connection?.nodes.length, refetch, variables])
|
||||
} as Partial<TVariables>)
|
||||
}, [first, refetch, variables])
|
||||
|
||||
// Refetch the first page. Use this function if the number of nodes in the
|
||||
// connection might have changed since the last refetch.
|
||||
const refetchFirst = useCallback(async (): Promise<void> => {
|
||||
// Reset connection state to just fetch the first page.
|
||||
setConnectionState(prev => ({ ...prev, first: pageSize }))
|
||||
await refetch({
|
||||
...variables,
|
||||
first,
|
||||
})
|
||||
}, [first, refetch, variables])
|
||||
} as Partial<TVariables>)
|
||||
}, [first, pageSize, refetch, setConnectionState, variables])
|
||||
|
||||
// We use `refetchAll` to poll for all the nodes currently loaded in the
|
||||
// connection, vs. just providing a `pollInterval` to the underlying `useQuery`, which
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
|
||||
import { getUrlQuery, type GetUrlQueryParameters } from '../utils'
|
||||
|
||||
interface UseShowMorePaginationURLParameters extends Pick<GetUrlQueryParameters, 'first' | 'visibleResultCount'> {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook replicates how FilteredConnection updates the URL when key variables change.
|
||||
* We use this to ensure the URL is kept in sync with the current connection state.
|
||||
* This is to allow users to build complex requests that can still be shared with others.
|
||||
* It is closely coupled to useShowMorePagination, which also derives initial state from the URL.
|
||||
*/
|
||||
export const useShowMorePaginationUrl = ({
|
||||
enabled,
|
||||
first,
|
||||
visibleResultCount,
|
||||
}: UseShowMorePaginationURLParameters): void => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const searchFragment = getUrlQuery({
|
||||
first,
|
||||
visibleResultCount,
|
||||
search: location.search,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && searchFragment && location.search !== `?${searchFragment}`) {
|
||||
navigate(
|
||||
{
|
||||
search: searchFragment,
|
||||
hash: location.hash,
|
||||
},
|
||||
{ replace: true }
|
||||
)
|
||||
}
|
||||
}, [enabled, navigate, location.hash, location.search, searchFragment])
|
||||
}
|
||||
@ -9,7 +9,7 @@ import { FilterControl, type Filter, type FilterOption, type FilterValues } from
|
||||
|
||||
import styles from './ConnectionForm.module.scss'
|
||||
|
||||
export interface ConnectionFormProps {
|
||||
export interface ConnectionFormProps<TFilterKey extends string = string> {
|
||||
/** Hides the search input field. */
|
||||
hideSearch?: boolean
|
||||
|
||||
@ -42,14 +42,14 @@ export interface ConnectionFormProps {
|
||||
*
|
||||
* Filters are mutually exclusive.
|
||||
*/
|
||||
filters?: Filter[]
|
||||
filters?: Filter<TFilterKey>[]
|
||||
|
||||
onFilterSelect?: (filter: Filter, value: FilterOption['value'] | null) => void
|
||||
onFilterSelect?: (filter: Filter<TFilterKey>, value: FilterOption['value'] | undefined) => void
|
||||
|
||||
/** An element rendered as a sibling of the filters. */
|
||||
additionalFilterElement?: React.ReactElement
|
||||
|
||||
filterValues?: FilterValues
|
||||
filterValues?: FilterValues<TFilterKey>
|
||||
|
||||
compact?: boolean
|
||||
}
|
||||
@ -58,7 +58,9 @@ export interface ConnectionFormProps {
|
||||
* FilteredConnection form input.
|
||||
* Supports <input> for querying and <select>/<radio> controls for filtering
|
||||
*/
|
||||
export const ConnectionForm = React.forwardRef<HTMLInputElement, ConnectionFormProps>(
|
||||
export const ConnectionForm: <TFilterKey extends string = string>(
|
||||
props: ConnectionFormProps<TFilterKey> & { ref?: React.Ref<HTMLInputElement> }
|
||||
) => JSX.Element | null = React.forwardRef<HTMLInputElement, ConnectionFormProps<any>>(
|
||||
(
|
||||
{
|
||||
hideSearch,
|
||||
@ -122,4 +124,3 @@ export const ConnectionForm = React.forwardRef<HTMLInputElement, ConnectionFormP
|
||||
)
|
||||
}
|
||||
)
|
||||
ConnectionForm.displayName = 'ConnectionForm'
|
||||
|
||||
@ -3,7 +3,7 @@ import classNames from 'classnames'
|
||||
import { pluralize } from '@sourcegraph/common'
|
||||
import { Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import { type ConnectionNodesState, type ConnectionProps, getTotalCount } from '../ConnectionNodes'
|
||||
import type { ConnectionNodesState, ConnectionProps } from '../ConnectionNodes'
|
||||
import type { Connection } from '../ConnectionType'
|
||||
|
||||
import styles from './ConnectionSummary.module.scss'
|
||||
@ -17,7 +17,6 @@ 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
|
||||
@ -44,12 +43,12 @@ export const ConnectionSummary = <C extends Connection<N>, N, NP = {}, HP = {}>(
|
||||
pluralNoun,
|
||||
connectionQuery,
|
||||
emptyElement,
|
||||
first,
|
||||
compact,
|
||||
centered,
|
||||
className,
|
||||
}: ConnectionNodesSummaryProps<C, N, NP, HP>): JSX.Element | null => {
|
||||
const shouldShowSummary = !noSummaryIfAllNodesVisible || connection.nodes.length === 0 || hasNextPage
|
||||
const shouldShowSummary =
|
||||
(!noSummaryIfAllNodesVisible && connection.nodes.length > 0) || connection.nodes.length === 0 || hasNextPage
|
||||
const summaryClassName = classNames(
|
||||
compact && styles.compact,
|
||||
centered && styles.centered,
|
||||
@ -61,8 +60,7 @@ 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)
|
||||
const totalCount = typeof connection.totalCount === 'number' ? connection.totalCount : null
|
||||
|
||||
if (totalCount !== null && totalCount > 0 && TotalCountSummaryComponent) {
|
||||
return <TotalCountSummaryComponent totalCount={totalCount} />
|
||||
@ -94,6 +92,10 @@ export const ConnectionSummary = <C extends Connection<N>, N, NP = {}, HP = {}>(
|
||||
return null
|
||||
}
|
||||
|
||||
if (totalCount === null && connection.nodes.length > 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
emptyElement || (
|
||||
<Text className={summaryClassName} data-testid="summary">
|
||||
|
||||
188
client/web/src/components/FilteredConnection/utils.test.ts
Normal file
188
client/web/src/components/FilteredConnection/utils.test.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { Filter } from './FilterControl'
|
||||
import { getFilterFromURL, urlSearchParamsForFilteredConnection } from './utils'
|
||||
|
||||
describe('getFilterFromURL', () => {
|
||||
test('correct filter values from URL parameters', () => {
|
||||
const searchParams = new URLSearchParams('filter1=value2&filter2=value1')
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
id: 'filter1',
|
||||
label: 'Filter 1',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Option 1', value: 'value1', args: {} },
|
||||
{ label: 'Option 2', value: 'value2', args: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'filter2',
|
||||
label: 'Filter 2',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Option 1', value: 'value1', args: {} },
|
||||
{ label: 'Option 2', value: 'value2', args: {} },
|
||||
],
|
||||
},
|
||||
]
|
||||
expect(getFilterFromURL(searchParams, filters)).toEqual({
|
||||
filter1: 'value2',
|
||||
filter2: 'value1',
|
||||
})
|
||||
})
|
||||
|
||||
test('use first option value when URL parameters are not present', () => {
|
||||
const searchParams = new URLSearchParams()
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
id: 'filter1',
|
||||
label: 'Filter 1',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Option 1', value: 'value1', args: {} },
|
||||
{ label: 'Option 2', value: 'value2', args: {} },
|
||||
],
|
||||
},
|
||||
]
|
||||
expect(getFilterFromURL(searchParams, filters)).toEqual({
|
||||
filter1: 'value1',
|
||||
})
|
||||
})
|
||||
|
||||
test('return an empty object when filters are undefined', () => {
|
||||
const searchParams = new URLSearchParams('filter1=value1')
|
||||
expect(getFilterFromURL(searchParams, undefined)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('urlSearchParamsForFilteredConnection', () => {
|
||||
test('generate correct URL query string', () => {
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { first: 20 },
|
||||
pageSize: 10,
|
||||
query: 'test query',
|
||||
filterValues: { status: 'open', type: 'issue' },
|
||||
filters: [
|
||||
{
|
||||
id: 'status',
|
||||
type: 'select',
|
||||
label: 'l',
|
||||
options: [
|
||||
{ value: 'all', label: 'l', args: {} },
|
||||
{ value: 'open', label: 'l', args: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
type: 'select',
|
||||
label: 'l',
|
||||
options: [
|
||||
{ value: 'all', label: 'l', args: {} },
|
||||
{ value: 'issue', label: 'l', args: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
search: '?existing=param',
|
||||
}).toString()
|
||||
).toBe('existing=param&query=test+query&first=20&status=open&type=issue')
|
||||
})
|
||||
|
||||
test('omit default values', () => {
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { first: 10 },
|
||||
pageSize: 10,
|
||||
query: '',
|
||||
filterValues: { status: 'all' },
|
||||
filters: [
|
||||
{
|
||||
id: 'status',
|
||||
type: 'select',
|
||||
label: 'l',
|
||||
options: [
|
||||
{ value: 'all', label: 'l', args: {} },
|
||||
{ value: 'open', label: 'l', args: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
search: '',
|
||||
}).toString()
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
test('omit first/last only when implicit', () => {
|
||||
// Implicit `first`.
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { first: 10 },
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
}).toString()
|
||||
).toBe('')
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { first: 10, after: 'A' },
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
}).toString()
|
||||
).toBe('after=A')
|
||||
|
||||
// Implicit `last`.
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { last: 10, before: 'B' },
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
}).toString()
|
||||
).toBe('before=B')
|
||||
|
||||
// Non-implicit `first`.
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { first: 10, before: 'B' },
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
}).toString()
|
||||
).toBe('first=10&before=B')
|
||||
|
||||
// Non-implicit `last`.
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { last: 10 },
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
}).toString()
|
||||
).toBe('last=10')
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { first: 10, last: 10 },
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
}).toString()
|
||||
).toBe('first=10&last=10')
|
||||
})
|
||||
|
||||
test('undefined query clears query in URL', () => {
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
query: undefined,
|
||||
search: 'query=foo',
|
||||
}).toString()
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
test('preserves existing search', () => {
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
query: 'x',
|
||||
search: 'foo=bar',
|
||||
}).toString()
|
||||
).toBe('foo=bar&query=x')
|
||||
})
|
||||
|
||||
test('handle empty input', () => {
|
||||
expect(urlSearchParamsForFilteredConnection({ search: '' }).toString()).toBe('')
|
||||
})
|
||||
})
|
||||
@ -8,19 +8,27 @@ import type { Scalars } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import type { Connection } from './ConnectionType'
|
||||
import { QUERY_KEY } from './constants'
|
||||
import type { Filter, FilterValues } from './FilterControl'
|
||||
import type { PaginatedConnectionQueryArguments } from './hooks/usePageSwitcherPagination'
|
||||
|
||||
/** Checks if the passed value satisfies the GraphQL Node interface */
|
||||
export const hasID = (value: unknown): value is { id: Scalars['ID'] } =>
|
||||
typeof value === 'object' && value !== null && hasProperty('id')(value) && typeof value.id === 'string'
|
||||
export function hasID(value: unknown): value is { id: Scalars['ID'] } {
|
||||
return typeof value === 'object' && value !== null && hasProperty('id')(value) && typeof value.id === 'string'
|
||||
}
|
||||
|
||||
export const hasDisplayName = (value: unknown): value is { displayName: Scalars['String'] } =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
hasProperty('displayName')(value) &&
|
||||
typeof value.displayName === 'string'
|
||||
export function hasDisplayName(value: unknown): value is { displayName: Scalars['String'] } {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
hasProperty('displayName')(value) &&
|
||||
typeof value.displayName === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export const getFilterFromURL = (searchParameters: URLSearchParams, filters: Filter[] | undefined): FilterValues => {
|
||||
const values: FilterValues = {}
|
||||
export function getFilterFromURL<K extends string>(
|
||||
searchParameters: URLSearchParams,
|
||||
filters: Filter<K>[] | undefined
|
||||
): FilterValues<K> {
|
||||
const values: FilterValues<K> = {}
|
||||
if (filters === undefined) {
|
||||
return values
|
||||
}
|
||||
@ -39,7 +47,7 @@ export const getFilterFromURL = (searchParameters: URLSearchParams, filters: Fil
|
||||
return values
|
||||
}
|
||||
|
||||
export const parseQueryInt = (searchParameters: URLSearchParams, name: string): number | null => {
|
||||
export function parseQueryInt(searchParameters: URLSearchParams, name: string): number | null {
|
||||
const valueString = searchParameters.get(name)
|
||||
if (valueString === null) {
|
||||
return null
|
||||
@ -60,69 +68,83 @@ export const hasNextPage = (connection: Connection<unknown>): boolean =>
|
||||
? connection.pageInfo.hasNextPage
|
||||
: typeof connection.totalCount === 'number' && connection.nodes.length < connection.totalCount
|
||||
|
||||
export interface GetUrlQueryParameters {
|
||||
first?: {
|
||||
actual: number
|
||||
default: number
|
||||
}
|
||||
/**
|
||||
* Determines the URL search parameters for a connection. All of the parameters that may be used in
|
||||
* a filtered connection are handled here: search query, filters (where the URL querystring params
|
||||
* differ from the actual args that are passed as GraphQL variables), connection pagination params
|
||||
* like `first` and `after`, etc.
|
||||
*/
|
||||
export function urlSearchParamsForFilteredConnection({
|
||||
pagination,
|
||||
pageSize,
|
||||
query,
|
||||
filterValues,
|
||||
filters,
|
||||
search,
|
||||
}: {
|
||||
pagination?: PaginatedConnectionQueryArguments
|
||||
pageSize?: number
|
||||
query?: string
|
||||
filterValues?: FilterValues
|
||||
filters?: Filter[]
|
||||
visibleResultCount?: number
|
||||
search: Location['search']
|
||||
}
|
||||
}): URLSearchParams {
|
||||
const params = new URLSearchParams(search)
|
||||
|
||||
/**
|
||||
* Determines the URL search parameters for a connection.
|
||||
*/
|
||||
export const getUrlQuery = ({
|
||||
first,
|
||||
query,
|
||||
filterValues,
|
||||
visibleResultCount,
|
||||
filters,
|
||||
search,
|
||||
}: GetUrlQueryParameters): string => {
|
||||
const searchParameters = new URLSearchParams(search)
|
||||
setOrDeleteSearchParam(params, QUERY_KEY, query)
|
||||
|
||||
if (query) {
|
||||
searchParameters.set(QUERY_KEY, query)
|
||||
}
|
||||
|
||||
if (!!first && first.actual !== first.default) {
|
||||
searchParameters.set('first', String(first.actual))
|
||||
if (pagination) {
|
||||
// Omit `first` or `last` if their value is the default page size and if they are implicit
|
||||
// because it's just noise in the URL.
|
||||
const firstIfNonDefault =
|
||||
pageSize !== undefined && pagination.first === pageSize && !pagination.before && !pagination.last
|
||||
? null
|
||||
: pagination.first
|
||||
const lastIfNonDefault =
|
||||
pageSize !== undefined &&
|
||||
pagination.last === pageSize &&
|
||||
pagination.before &&
|
||||
!pagination.after &&
|
||||
!pagination.first
|
||||
? null
|
||||
: pagination.last
|
||||
setOrDeleteSearchParam(params, 'first', firstIfNonDefault)
|
||||
setOrDeleteSearchParam(params, 'last', lastIfNonDefault)
|
||||
setOrDeleteSearchParam(params, 'before', pagination.before)
|
||||
setOrDeleteSearchParam(params, 'after', pagination.after)
|
||||
}
|
||||
|
||||
if (filterValues && filters) {
|
||||
for (const filter of filters) {
|
||||
const value = filterValues[filter.id]
|
||||
if (value === undefined || value === null) {
|
||||
continue
|
||||
}
|
||||
if (value !== filter.options[0].value) {
|
||||
searchParameters.set(filter.id, value)
|
||||
const defaultValue = filter.options[0].value
|
||||
if (value !== undefined && value !== null && value !== defaultValue) {
|
||||
params.set(filter.id, value)
|
||||
} else {
|
||||
searchParameters.delete(filter.id)
|
||||
params.delete(filter.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleResultCount && visibleResultCount !== 0 && visibleResultCount !== first?.actual) {
|
||||
searchParameters.set('visible', String(visibleResultCount))
|
||||
}
|
||||
|
||||
return searchParameters.toString()
|
||||
return params
|
||||
}
|
||||
|
||||
interface AsGraphQLResultParameters<TResult> {
|
||||
data?: TResult
|
||||
errors: readonly GraphQLError[]
|
||||
function setOrDeleteSearchParam(
|
||||
params: URLSearchParams,
|
||||
name: string,
|
||||
value: string | number | null | undefined
|
||||
): void {
|
||||
if (value !== null && value !== undefined && value !== '' && value !== 0) {
|
||||
params.set(name, value.toString())
|
||||
} else {
|
||||
params.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map non-conforming GraphQL responses to a GraphQLResult.
|
||||
*/
|
||||
export const asGraphQLResult = <T>({ data, errors }: AsGraphQLResultParameters<T>): GraphQLResult<T> => {
|
||||
export function asGraphQLResult<T>({ data, errors }: { data?: T; errors: readonly GraphQLError[] }): GraphQLResult<T> {
|
||||
if (!data) {
|
||||
return { data: null, errors }
|
||||
}
|
||||
|
||||
@ -47,8 +47,6 @@ export const ExternalServicesPage: FC<Props> = ({
|
||||
const repoID = searchParameters.get('repoID') || null
|
||||
|
||||
const { loading, hasNextPage, fetchMore, connection, error } = useExternalServicesConnection({
|
||||
first: null,
|
||||
after: null,
|
||||
repo: repoID,
|
||||
})
|
||||
|
||||
@ -95,7 +93,6 @@ export const ExternalServicesPage: FC<Props> = ({
|
||||
<SummaryContainer className="mt-2" centered={true}>
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={false}
|
||||
first={connection.totalCount ?? 0}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="code host connection"
|
||||
|
||||
@ -1,39 +1,40 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
import type { QueryTuple, MutationTuple, QueryResult } from '@apollo/client'
|
||||
import type { MutationTuple, QueryResult, QueryTuple } from '@apollo/client'
|
||||
import { parse } from 'jsonc-parser'
|
||||
import { type Observable, lastValueFrom } from 'rxjs'
|
||||
import { lastValueFrom, type Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
|
||||
import { createAggregateError } from '@sourcegraph/common'
|
||||
import { gql, dataOrThrowErrors, useMutation, useLazyQuery, useQuery } from '@sourcegraph/http-client'
|
||||
import { dataOrThrowErrors, gql, useLazyQuery, useMutation, useQuery } from '@sourcegraph/http-client'
|
||||
|
||||
import { requestGraphQL } from '../../backend/graphql'
|
||||
import type {
|
||||
UpdateExternalServiceResult,
|
||||
UpdateExternalServiceVariables,
|
||||
Scalars,
|
||||
AddExternalServiceVariables,
|
||||
AddExternalServiceResult,
|
||||
DeleteExternalServiceVariables,
|
||||
DeleteExternalServiceResult,
|
||||
ExternalServicesVariables,
|
||||
ExternalServicesResult,
|
||||
ExternalServiceCheckConnectionByIdVariables,
|
||||
ExternalServiceCheckConnectionByIdResult,
|
||||
SyncExternalServiceResult,
|
||||
SyncExternalServiceVariables,
|
||||
ExternalServiceSyncJobsVariables,
|
||||
ExternalServiceSyncJobConnectionFields,
|
||||
ExternalServiceSyncJobsResult,
|
||||
CancelExternalServiceSyncVariables,
|
||||
AddExternalServiceVariables,
|
||||
CancelExternalServiceSyncResult,
|
||||
ListExternalServiceFields,
|
||||
CancelExternalServiceSyncVariables,
|
||||
DeleteExternalServiceResult,
|
||||
DeleteExternalServiceVariables,
|
||||
ExternalServiceCheckConnectionByIdResult,
|
||||
ExternalServiceCheckConnectionByIdVariables,
|
||||
ExternalServiceFields,
|
||||
ExternalServiceResult,
|
||||
ExternalServiceSyncJobConnectionFields,
|
||||
ExternalServiceSyncJobsResult,
|
||||
ExternalServiceSyncJobsVariables,
|
||||
ExternalServiceVariables,
|
||||
ExternalServicesResult,
|
||||
ExternalServicesVariables,
|
||||
ListExternalServiceFields,
|
||||
Scalars,
|
||||
SyncExternalServiceResult,
|
||||
SyncExternalServiceVariables,
|
||||
UpdateExternalServiceResult,
|
||||
UpdateExternalServiceVariables,
|
||||
} from '../../graphql-operations'
|
||||
import {
|
||||
ShowMoreConnectionQueryArguments,
|
||||
useShowMorePagination,
|
||||
type UseShowMorePaginationResult,
|
||||
} from '../FilteredConnection/hooks/useShowMorePagination'
|
||||
@ -283,11 +284,11 @@ export const EXTERNAL_SERVICE_IDS_AND_NAMES = gql`
|
||||
`
|
||||
|
||||
export const useExternalServicesConnection = (
|
||||
vars: ExternalServicesVariables
|
||||
vars: Omit<ExternalServicesVariables, keyof ShowMoreConnectionQueryArguments>
|
||||
): UseShowMorePaginationResult<ExternalServicesResult, ListExternalServiceFields> =>
|
||||
useShowMorePagination<ExternalServicesResult, ExternalServicesVariables, ListExternalServiceFields>({
|
||||
query: EXTERNAL_SERVICES,
|
||||
variables: { after: vars.after, first: vars.first ?? 10, repo: vars.repo },
|
||||
variables: { repo: vars.repo },
|
||||
getConnection: result => {
|
||||
const { externalServices } = dataOrThrowErrors(result)
|
||||
return externalServices
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState, type FC } from 'react'
|
||||
|
||||
import { mdiCog, mdiDelete, mdiOpenInNew, mdiPlus } from '@mdi/js'
|
||||
import classNames from 'classnames'
|
||||
@ -10,26 +10,26 @@ import { useQuery } from '@sourcegraph/http-client'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import {
|
||||
AnchorLink,
|
||||
Button,
|
||||
ButtonLink,
|
||||
Container,
|
||||
ErrorAlert,
|
||||
PageHeader,
|
||||
ButtonLink,
|
||||
Icon,
|
||||
LoadingSpinner,
|
||||
Button,
|
||||
Grid,
|
||||
H2,
|
||||
H3,
|
||||
Icon,
|
||||
Link,
|
||||
LoadingSpinner,
|
||||
PageHeader,
|
||||
Text,
|
||||
Grid,
|
||||
AnchorLink,
|
||||
} from '@sourcegraph/wildcard'
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import type { BreadcrumbItem } from '@sourcegraph/wildcard/src/components/PageHeader'
|
||||
|
||||
import { GitHubAppDomain, type GitHubAppByIDResult, type GitHubAppByIDVariables } from '../../graphql-operations'
|
||||
import { ExternalServiceNode } from '../externalServices/ExternalServiceNode'
|
||||
import { ConnectionList, SummaryContainer, ConnectionSummary } from '../FilteredConnection/ui'
|
||||
import { ConnectionList, ConnectionSummary, SummaryContainer } from '../FilteredConnection/ui'
|
||||
import { PageTitle } from '../PageTitle'
|
||||
|
||||
import { AppLogo } from './AppLogo'
|
||||
@ -274,7 +274,6 @@ export const GitHubAppPage: FC<Props> = ({
|
||||
<SummaryContainer className="mt-2" centered={true}>
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={false}
|
||||
first={100}
|
||||
centered={true}
|
||||
connection={installation.externalServices}
|
||||
noun="code host connection"
|
||||
@ -297,7 +296,6 @@ export const GitHubAppPage: FC<Props> = ({
|
||||
<SummaryContainer className="mt-3" centered={true}>
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={false}
|
||||
first={app?.installations?.length ?? 0}
|
||||
centered={true}
|
||||
connection={{
|
||||
nodes: app?.installations ?? [],
|
||||
|
||||
@ -9,11 +9,11 @@ import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
|
||||
import { ButtonLink, Container, ErrorAlert, Icon, Link, LoadingSpinner, PageHeader } from '@sourcegraph/wildcard'
|
||||
|
||||
import { type GitHubAppsResult, type GitHubAppsVariables, GitHubAppDomain } from '../../graphql-operations'
|
||||
import { GitHubAppDomain, type GitHubAppsResult, type GitHubAppsVariables } from '../../graphql-operations'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
ConnectionLoading,
|
||||
ConnectionList,
|
||||
ConnectionLoading,
|
||||
ConnectionSummary,
|
||||
SummaryContainer,
|
||||
} from '../FilteredConnection/ui'
|
||||
@ -103,7 +103,6 @@ export const GitHubAppsPage: React.FC<Props> = ({ batchChangesEnabled, telemetry
|
||||
<div className="text-center text-muted">You haven't created any GitHub Apps yet.</div>
|
||||
}
|
||||
noSummaryIfAllNodesVisible={false}
|
||||
first={gitHubApps?.length ?? 0}
|
||||
centered={true}
|
||||
connection={{
|
||||
nodes: gitHubApps ?? [],
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react'
|
||||
|
||||
import { mdiImport } from '@mdi/js'
|
||||
|
||||
import { Icon, H3, H4, LinkOrSpan } from '@sourcegraph/wildcard'
|
||||
import { H3, H4, Icon, LinkOrSpan } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { UseShowMorePaginationResult } from '../../../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
@ -31,8 +31,6 @@ interface ImportingChangesetsPreviewListProps {
|
||||
isStale: boolean
|
||||
}
|
||||
|
||||
const CHANGESETS_PER_PAGE_COUNT = 100
|
||||
|
||||
export const ImportingChangesetsPreviewList: React.FunctionComponent<
|
||||
React.PropsWithChildren<ImportingChangesetsPreviewListProps>
|
||||
> = ({ importingChangesetsConnection: { connection, hasNextPage, fetchMore, loading }, isStale }) => (
|
||||
@ -67,7 +65,6 @@ export const ImportingChangesetsPreviewList: React.FunctionComponent<
|
||||
<ConnectionSummary
|
||||
centered={true}
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={CHANGESETS_PER_PAGE_COUNT}
|
||||
connection={connection}
|
||||
noun="imported changeset"
|
||||
pluralNoun="imported changesets"
|
||||
|
||||
@ -6,9 +6,9 @@ import {
|
||||
ConnectionContainer,
|
||||
ConnectionError,
|
||||
ConnectionList,
|
||||
SummaryContainer,
|
||||
ConnectionSummary,
|
||||
ShowMoreButton,
|
||||
SummaryContainer,
|
||||
} from '../../../../../components/FilteredConnection/ui'
|
||||
import type {
|
||||
BatchSpecWorkspacesPreviewResult,
|
||||
@ -16,7 +16,6 @@ import type {
|
||||
PreviewVisibleBatchSpecWorkspaceFields,
|
||||
} from '../../../../../graphql-operations'
|
||||
|
||||
import { WORKSPACES_PER_PAGE_COUNT } from './useWorkspaces'
|
||||
import { WorkspacesPreviewListItem } from './WorkspacesPreviewListItem'
|
||||
|
||||
interface WorkspacesPreviewListProps {
|
||||
@ -90,7 +89,6 @@ export const WorkspacesPreviewList: React.FunctionComponent<React.PropsWithChild
|
||||
<ConnectionSummary
|
||||
centered={true}
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={WORKSPACES_PER_PAGE_COUNT}
|
||||
connection={connectionOrCached}
|
||||
noun="workspace"
|
||||
pluralNoun="workspaces"
|
||||
|
||||
@ -5,10 +5,10 @@ import {
|
||||
type UseShowMorePaginationResult,
|
||||
} from '../../../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import type {
|
||||
Scalars,
|
||||
PreviewBatchSpecImportingChangesetFields,
|
||||
BatchSpecImportingChangesetsResult,
|
||||
BatchSpecImportingChangesetsVariables,
|
||||
PreviewBatchSpecImportingChangesetFields,
|
||||
Scalars,
|
||||
} from '../../../../../graphql-operations'
|
||||
import { IMPORTING_CHANGESETS } from '../../../create/backend'
|
||||
|
||||
@ -34,11 +34,9 @@ export const useImportingChangesets = (
|
||||
query: IMPORTING_CHANGESETS,
|
||||
variables: {
|
||||
batchSpec: batchSpecID,
|
||||
after: null,
|
||||
first: CHANGESETS_PER_PAGE_COUNT,
|
||||
},
|
||||
options: {
|
||||
useURL: false,
|
||||
pageSize: CHANGESETS_PER_PAGE_COUNT,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
getConnection: result => {
|
||||
|
||||
@ -42,12 +42,10 @@ export const useWorkspaces = (
|
||||
query: WORKSPACES,
|
||||
variables: {
|
||||
batchSpec: batchSpecID,
|
||||
after: null,
|
||||
first: WORKSPACES_PER_PAGE_COUNT,
|
||||
search: filters?.search ?? null,
|
||||
},
|
||||
options: {
|
||||
useURL: false,
|
||||
pageSize: WORKSPACES_PER_PAGE_COUNT,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
getConnection: result => {
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
mdiClose,
|
||||
mdiTimelineClockOutline,
|
||||
mdiSourceBranch,
|
||||
mdiEyeOffOutline,
|
||||
mdiSync,
|
||||
mdiLinkVariantRemove,
|
||||
mdiChevronDown,
|
||||
mdiChevronUp,
|
||||
mdiClose,
|
||||
mdiEyeOffOutline,
|
||||
mdiLinkVariantRemove,
|
||||
mdiOpenInNew,
|
||||
mdiSourceBranch,
|
||||
mdiSync,
|
||||
mdiTimelineClockOutline,
|
||||
} from '@mdi/js'
|
||||
import { VisuallyHidden } from '@reach/visually-hidden'
|
||||
import classNames from 'classnames'
|
||||
@ -21,30 +21,30 @@ import type { Maybe } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
Code,
|
||||
Collapse,
|
||||
CollapseHeader,
|
||||
CollapsePanel,
|
||||
ErrorAlert,
|
||||
H1,
|
||||
H3,
|
||||
H4,
|
||||
Heading,
|
||||
Icon,
|
||||
Link,
|
||||
LoadingSpinner,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
Button,
|
||||
Link,
|
||||
CardBody,
|
||||
Card,
|
||||
Icon,
|
||||
Code,
|
||||
H1,
|
||||
H3,
|
||||
H4,
|
||||
Text,
|
||||
Alert,
|
||||
CollapsePanel,
|
||||
CollapseHeader,
|
||||
Collapse,
|
||||
Heading,
|
||||
Tooltip,
|
||||
ErrorAlert,
|
||||
} from '@sourcegraph/wildcard'
|
||||
|
||||
import { DiffStat } from '../../../../../components/diff/DiffStat'
|
||||
@ -55,23 +55,23 @@ import { HeroPage } from '../../../../../components/HeroPage'
|
||||
import { LogOutput } from '../../../../../components/LogOutput'
|
||||
import { Duration } from '../../../../../components/time/Duration'
|
||||
import {
|
||||
type BatchSpecWorkspaceChangesetSpecFields,
|
||||
BatchSpecWorkspaceState,
|
||||
type BatchSpecWorkspaceChangesetSpecFields,
|
||||
type BatchSpecWorkspaceStepFields,
|
||||
type BatchSpecWorkspaceStepResult,
|
||||
type BatchSpecWorkspaceStepVariables,
|
||||
type FileDiffFields,
|
||||
type HiddenBatchSpecWorkspaceFields,
|
||||
type Scalars,
|
||||
type VisibleBatchSpecWorkspaceFields,
|
||||
type FileDiffFields,
|
||||
type BatchSpecWorkspaceStepResult,
|
||||
type BatchSpecWorkspaceStepVariables,
|
||||
} from '../../../../../graphql-operations'
|
||||
import { queryChangesetSpecFileDiffs as _queryChangesetSpecFileDiffs } from '../../../preview/list/backend'
|
||||
import { ChangesetSpecFileDiffConnection } from '../../../preview/list/ChangesetSpecFileDiffConnection'
|
||||
import {
|
||||
useBatchSpecWorkspace,
|
||||
useRetryWorkspaceExecution,
|
||||
queryBatchSpecWorkspaceStepFileDiffs as _queryBatchSpecWorkspaceStepFileDiffs,
|
||||
BATCH_SPEC_WORKSPACE_STEP,
|
||||
useBatchSpecWorkspace,
|
||||
useRetryWorkspaceExecution,
|
||||
} from '../backend'
|
||||
import { DiagnosticsModal } from '../DiagnosticsModal'
|
||||
|
||||
@ -532,11 +532,9 @@ export const WorkspaceStepOutputLines: React.FunctionComponent<
|
||||
variables: {
|
||||
workspaceID,
|
||||
stepIndex: step.number,
|
||||
first: OUTPUT_LINES_PER_PAGE,
|
||||
after: null,
|
||||
},
|
||||
options: {
|
||||
useURL: false,
|
||||
pageSize: OUTPUT_LINES_PER_PAGE,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
getConnection: result => {
|
||||
|
||||
@ -54,7 +54,6 @@ export const BulkOperationsTab: React.FunctionComponent<React.PropsWithChildren<
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
centered={true}
|
||||
first={BATCH_COUNT}
|
||||
connection={connection}
|
||||
noun="bulk operation"
|
||||
pluralNoun="bulk operations"
|
||||
@ -89,11 +88,9 @@ const useBulkOperationsListConnection = (
|
||||
query: BULK_OPERATIONS,
|
||||
variables: {
|
||||
batchChange: batchChangeID,
|
||||
after: null,
|
||||
first: BATCH_COUNT,
|
||||
},
|
||||
options: {
|
||||
useURL: true,
|
||||
pageSize: BATCH_COUNT,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
getConnection: result => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect, useContext } from 'react'
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Subject } from 'rxjs'
|
||||
|
||||
@ -6,6 +6,7 @@ import { dataOrThrowErrors } from '@sourcegraph/http-client'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Container } from '@sourcegraph/wildcard'
|
||||
|
||||
import { useUrlSearchParamsForConnectionState } from '../../../../components/FilteredConnection/hooks/connectionState'
|
||||
import { useShowMorePagination } from '../../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
@ -17,22 +18,22 @@ import {
|
||||
SummaryContainer,
|
||||
} from '../../../../components/FilteredConnection/ui'
|
||||
import {
|
||||
BatchChangeState,
|
||||
type BatchChangeChangesetsResult,
|
||||
type BatchChangeChangesetsVariables,
|
||||
type ExternalChangesetFields,
|
||||
type HiddenExternalChangesetFields,
|
||||
type Scalars,
|
||||
type BatchChangeChangesetsResult,
|
||||
type BatchChangeChangesetsVariables,
|
||||
BatchChangeState,
|
||||
} from '../../../../graphql-operations'
|
||||
import { MultiSelectContext, MultiSelectContextProvider } from '../../MultiSelectContext'
|
||||
import {
|
||||
type queryExternalChangesetWithFileDiffs as _queryExternalChangesetWithFileDiffs,
|
||||
queryAllChangesetIDs as _queryAllChangesetIDs,
|
||||
CHANGESETS,
|
||||
queryAllChangesetIDs as _queryAllChangesetIDs,
|
||||
type queryExternalChangesetWithFileDiffs as _queryExternalChangesetWithFileDiffs,
|
||||
} from '../backend'
|
||||
|
||||
import { BatchChangeChangesetsHeader } from './BatchChangeChangesetsHeader'
|
||||
import { type ChangesetFilters, ChangesetFilterRow } from './ChangesetFilterRow'
|
||||
import { ChangesetFilterRow, type ChangesetFilters } from './ChangesetFilterRow'
|
||||
import { ChangesetNode } from './ChangesetNode'
|
||||
import { ChangesetSelectRow } from './ChangesetSelectRow'
|
||||
import { EmptyArchivedChangesetListElement } from './EmptyArchivedChangesetListElement'
|
||||
@ -127,6 +128,7 @@ const BatchChangeChangesetsImpl: React.FunctionComponent<React.PropsWithChildren
|
||||
[changesetFilters, batchChangeID, onlyArchived]
|
||||
)
|
||||
|
||||
const connectionState = useUrlSearchParamsForConnectionState()
|
||||
const { connection, error, loading, fetchMore, hasNextPage } = useShowMorePagination<
|
||||
BatchChangeChangesetsResult,
|
||||
BatchChangeChangesetsVariables,
|
||||
@ -135,12 +137,10 @@ const BatchChangeChangesetsImpl: React.FunctionComponent<React.PropsWithChildren
|
||||
query: CHANGESETS,
|
||||
variables: {
|
||||
...queryArguments,
|
||||
first: BATCH_COUNT,
|
||||
after: null,
|
||||
onlyClosable: null,
|
||||
},
|
||||
options: {
|
||||
useURL: true,
|
||||
pageSize: BATCH_COUNT,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
pollInterval: 5000,
|
||||
},
|
||||
@ -155,6 +155,7 @@ const BatchChangeChangesetsImpl: React.FunctionComponent<React.PropsWithChildren
|
||||
}
|
||||
return data.node.changesets
|
||||
},
|
||||
state: connectionState,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@ -233,7 +234,6 @@ const BatchChangeChangesetsImpl: React.FunctionComponent<React.PropsWithChildren
|
||||
<SummaryContainer centered={true}>
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={BATCH_COUNT}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="changeset"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useCallback, useState, useMemo } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import classNames from 'classnames'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
@ -10,12 +10,13 @@ import type { SettingsCascadeProps } from '@sourcegraph/shared/src/settings/sett
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
|
||||
import { Button, PageHeader, Link, Container, H3, Text, screenReaderAnnounce } from '@sourcegraph/wildcard'
|
||||
import { Button, Container, H3, Link, PageHeader, screenReaderAnnounce, Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { AuthenticatedUser } from '../../../auth'
|
||||
import { isBatchChangesExecutionEnabled } from '../../../batches'
|
||||
import { BatchChangesIcon } from '../../../batches/icons'
|
||||
import { canWriteBatchChanges, NO_ACCESS_BATCH_CHANGES_WRITE, NO_ACCESS_NAMESPACE } from '../../../batches/utils'
|
||||
import { useUrlSearchParamsForConnectionState } from '../../../components/FilteredConnection/hooks/connectionState'
|
||||
import { useShowMorePagination } from '../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
@ -28,14 +29,14 @@ import {
|
||||
} from '../../../components/FilteredConnection/ui'
|
||||
import { Page } from '../../../components/Page'
|
||||
import type {
|
||||
ListBatchChange,
|
||||
Scalars,
|
||||
BatchChangesVariables,
|
||||
BatchChangesResult,
|
||||
BatchChangesByNamespaceResult,
|
||||
BatchChangesByNamespaceVariables,
|
||||
BatchChangesResult,
|
||||
BatchChangesVariables,
|
||||
GetLicenseAndUsageInfoResult,
|
||||
GetLicenseAndUsageInfoVariables,
|
||||
ListBatchChange,
|
||||
Scalars,
|
||||
} from '../../../graphql-operations'
|
||||
|
||||
import { BATCH_CHANGES, BATCH_CHANGES_BY_NAMESPACE, GET_LICENSE_AND_USAGE_INFO } from './backend'
|
||||
@ -117,6 +118,7 @@ export const BatchChangeListPage: React.FunctionComponent<React.PropsWithChildre
|
||||
{ onCompleted: onUsageCheckCompleted }
|
||||
)
|
||||
|
||||
const connectionState = useUrlSearchParamsForConnectionState()
|
||||
const { connection, error, loading, fetchMore, hasNextPage } = useShowMorePagination<
|
||||
BatchChangesByNamespaceResult | BatchChangesResult,
|
||||
BatchChangesByNamespaceVariables | BatchChangesVariables,
|
||||
@ -124,13 +126,11 @@ export const BatchChangeListPage: React.FunctionComponent<React.PropsWithChildre
|
||||
>({
|
||||
query: namespaceID ? BATCH_CHANGES_BY_NAMESPACE : BATCH_CHANGES,
|
||||
variables: {
|
||||
namespaceID,
|
||||
...(namespaceID ? { namespaceID } : undefined),
|
||||
states: selectedFilters,
|
||||
first: BATCH_CHANGES_PER_PAGE_COUNT,
|
||||
after: null,
|
||||
viewerCanAdminister: null,
|
||||
},
|
||||
options: { useURL: true },
|
||||
options: { pageSize: BATCH_CHANGES_PER_PAGE_COUNT },
|
||||
getConnection: result => {
|
||||
const data = dataOrThrowErrors(result)
|
||||
if (!namespaceID) {
|
||||
@ -145,6 +145,7 @@ export const BatchChangeListPage: React.FunctionComponent<React.PropsWithChildre
|
||||
|
||||
return data.node.batchChanges
|
||||
},
|
||||
state: connectionState,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@ -250,7 +251,6 @@ export const BatchChangeListPage: React.FunctionComponent<React.PropsWithChildre
|
||||
<ConnectionSummary
|
||||
centered={true}
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={BATCH_CHANGES_PER_PAGE_COUNT}
|
||||
connection={connection}
|
||||
noun="batch change"
|
||||
pluralNoun="batch changes"
|
||||
|
||||
@ -17,8 +17,8 @@ import {
|
||||
} from '../../../components/FilteredConnection/ui'
|
||||
import { GitHubAppFailureAlert } from '../../../components/gitHubApps/GitHubAppFailureAlert'
|
||||
import {
|
||||
type BatchChangesCodeHostFields,
|
||||
GitHubAppKind,
|
||||
type BatchChangesCodeHostFields,
|
||||
type GlobalBatchChangesCodeHostsResult,
|
||||
type UserAreaUserFields,
|
||||
type UserBatchChangesCodeHostsResult,
|
||||
@ -111,7 +111,6 @@ const CodeHostConnections: React.FunctionComponent<React.PropsWithChildren<CodeH
|
||||
<SummaryContainer className="mt-2">
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={15}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="code host"
|
||||
|
||||
@ -17,8 +17,8 @@ import {
|
||||
} from '../../../components/FilteredConnection/ui'
|
||||
import { GitHubAppFailureAlert } from '../../../components/gitHubApps/GitHubAppFailureAlert'
|
||||
import {
|
||||
type BatchChangesCodeHostFields,
|
||||
GitHubAppKind,
|
||||
type BatchChangesCodeHostFields,
|
||||
type GlobalBatchChangesCodeHostsResult,
|
||||
type Scalars,
|
||||
type UserBatchChangesCodeHostsResult,
|
||||
@ -107,7 +107,6 @@ export const CommitSigningIntegrations: React.FunctionComponent<
|
||||
<SummaryContainer className="mt-2">
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={30}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="code host commit signing integration"
|
||||
|
||||
@ -14,11 +14,11 @@ import type {
|
||||
DeleteBatchChangesCredentialVariables,
|
||||
GlobalBatchChangesCodeHostsResult,
|
||||
GlobalBatchChangesCodeHostsVariables,
|
||||
RefreshGitHubAppResult,
|
||||
RefreshGitHubAppVariables,
|
||||
Scalars,
|
||||
UserBatchChangesCodeHostsResult,
|
||||
UserBatchChangesCodeHostsVariables,
|
||||
RefreshGitHubAppResult,
|
||||
RefreshGitHubAppVariables,
|
||||
} from '../../../graphql-operations'
|
||||
|
||||
export const CREDENTIAL_FIELDS_FRAGMENT = gql`
|
||||
@ -139,8 +139,6 @@ export const useUserBatchChangesCodeHostConnection = (
|
||||
query: USER_CODE_HOSTS,
|
||||
variables: {
|
||||
user,
|
||||
after: null,
|
||||
first: 15,
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'network-only',
|
||||
@ -179,12 +177,8 @@ export const useGlobalBatchChangesCodeHostConnection = (): UseShowMorePagination
|
||||
BatchChangesCodeHostFields
|
||||
>({
|
||||
query: GLOBAL_CODE_HOSTS,
|
||||
variables: {
|
||||
after: null,
|
||||
first: 30,
|
||||
},
|
||||
variables: {},
|
||||
options: {
|
||||
useURL: true,
|
||||
fetchPolicy: 'network-only',
|
||||
},
|
||||
getConnection: result => {
|
||||
|
||||
@ -61,7 +61,7 @@ export const CodeMonitorList: React.FunctionComponent<React.PropsWithChildren<Co
|
||||
)
|
||||
|
||||
const queryAllConnection = useCallback(
|
||||
(args: Partial<ListAllCodeMonitorsVariables>) =>
|
||||
(args: Omit<Partial<ListAllCodeMonitorsVariables>, 'first'> & { first?: number | null }) =>
|
||||
fetchCodeMonitors({
|
||||
first: args.first ?? 10,
|
||||
after: args.after ?? null,
|
||||
|
||||
@ -125,7 +125,7 @@ export const CodeMonitoringLogs: React.FunctionComponent<
|
||||
CodeMonitorWithEvents
|
||||
>({
|
||||
query: CODE_MONITOR_EVENTS,
|
||||
variables: { first: pageSize, after: null, triggerEventsFirst: runPageSize, triggerEventsAfter: null },
|
||||
variables: { triggerEventsFirst: runPageSize, triggerEventsAfter: null },
|
||||
getConnection: result => {
|
||||
const data = dataOrThrowErrors(result)
|
||||
|
||||
@ -134,6 +134,7 @@ export const CodeMonitoringLogs: React.FunctionComponent<
|
||||
}
|
||||
return data.currentUser.monitors
|
||||
},
|
||||
options: { pageSize },
|
||||
})
|
||||
|
||||
const monitors: CodeMonitorWithEvents[] = useMemo(() => connection?.nodes ?? [], [connection])
|
||||
@ -158,7 +159,6 @@ export const CodeMonitoringLogs: React.FunctionComponent<
|
||||
<SummaryContainer centered={true}>
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={pageSize}
|
||||
connection={connection}
|
||||
noun="monitor"
|
||||
pluralNoun="monitors"
|
||||
|
||||
@ -4,7 +4,7 @@ import type { QueryResult } from '@apollo/client'
|
||||
|
||||
import { dataOrThrowErrors, useLazyQuery, useQuery } from '@sourcegraph/http-client'
|
||||
|
||||
import { type Location, buildPreciseLocation, LocationsGroup } from '../../codeintel/location'
|
||||
import { buildPreciseLocation, LocationsGroup, type Location } from '../../codeintel/location'
|
||||
import {
|
||||
LOAD_ADDITIONAL_IMPLEMENTATIONS_QUERY,
|
||||
LOAD_ADDITIONAL_PROTOTYPES_QUERY,
|
||||
@ -12,17 +12,16 @@ import {
|
||||
USE_PRECISE_CODE_INTEL_FOR_POSITION_QUERY,
|
||||
} from '../../codeintel/ReferencesPanelQueries'
|
||||
import type { CodeIntelData, UseCodeIntelParameters, UseCodeIntelResult } from '../../codeintel/useCodeIntel'
|
||||
import type { ConnectionQueryArguments } from '../../components/FilteredConnection'
|
||||
import { asGraphQLResult } from '../../components/FilteredConnection/utils'
|
||||
import type {
|
||||
UsePreciseCodeIntelForPositionVariables,
|
||||
UsePreciseCodeIntelForPositionResult,
|
||||
LoadAdditionalReferencesResult,
|
||||
LoadAdditionalReferencesVariables,
|
||||
LoadAdditionalImplementationsResult,
|
||||
LoadAdditionalImplementationsVariables,
|
||||
LoadAdditionalPrototypesResult,
|
||||
LoadAdditionalPrototypesVariables,
|
||||
LoadAdditionalReferencesResult,
|
||||
LoadAdditionalReferencesVariables,
|
||||
UsePreciseCodeIntelForPositionResult,
|
||||
UsePreciseCodeIntelForPositionVariables,
|
||||
} from '../../graphql-operations'
|
||||
|
||||
import { useSearchBasedCodeIntel } from './useSearchBasedCodeIntel'
|
||||
@ -88,56 +87,56 @@ export const useCodeIntel = ({
|
||||
getSetting,
|
||||
})
|
||||
|
||||
const { error, loading } = useQuery<
|
||||
UsePreciseCodeIntelForPositionResult,
|
||||
UsePreciseCodeIntelForPositionVariables & ConnectionQueryArguments
|
||||
>(USE_PRECISE_CODE_INTEL_FOR_POSITION_QUERY, {
|
||||
variables,
|
||||
notifyOnNetworkStatusChange: false,
|
||||
fetchPolicy: 'no-cache',
|
||||
onCompleted: result => {
|
||||
if (!shouldFetchPrecise.current) {
|
||||
return
|
||||
}
|
||||
shouldFetchPrecise.current = false
|
||||
|
||||
let refs: CodeIntelData['references'] = { endCursor: null, nodes: LocationsGroup.empty }
|
||||
let defs: CodeIntelData['definitions'] = { endCursor: null, nodes: LocationsGroup.empty }
|
||||
const addRefs = (newRefs: Location[]): void => {
|
||||
refs.nodes = refs.nodes.combine(newRefs)
|
||||
}
|
||||
const addDefs = (newDefs: Location[]): void => {
|
||||
defs.nodes = defs.nodes.combine(newDefs)
|
||||
}
|
||||
|
||||
const lsifData = result ? getLsifData({ data: result }) : undefined
|
||||
if (lsifData) {
|
||||
refs = lsifData.references
|
||||
defs = lsifData.definitions
|
||||
// If we've exhausted LSIF data and the flag is enabled, we add search-based data.
|
||||
if (refs.endCursor === null && shouldMixPreciseAndSearchBasedReferences()) {
|
||||
fetchSearchBasedReferences(addRefs)
|
||||
const { error, loading } = useQuery<UsePreciseCodeIntelForPositionResult, UsePreciseCodeIntelForPositionVariables>(
|
||||
USE_PRECISE_CODE_INTEL_FOR_POSITION_QUERY,
|
||||
{
|
||||
variables,
|
||||
notifyOnNetworkStatusChange: false,
|
||||
fetchPolicy: 'no-cache',
|
||||
onCompleted: result => {
|
||||
if (!shouldFetchPrecise.current) {
|
||||
return
|
||||
}
|
||||
// When no definitions are found, the hover tooltip falls back to a search based
|
||||
// search, regardless of the mixPreciseAndSearchBasedReferences setting.
|
||||
if (defs.nodes.locationsCount === 0) {
|
||||
fetchSearchBasedDefinitions(addDefs)
|
||||
shouldFetchPrecise.current = false
|
||||
|
||||
let refs: CodeIntelData['references'] = { endCursor: null, nodes: LocationsGroup.empty }
|
||||
let defs: CodeIntelData['definitions'] = { endCursor: null, nodes: LocationsGroup.empty }
|
||||
const addRefs = (newRefs: Location[]): void => {
|
||||
refs.nodes = refs.nodes.combine(newRefs)
|
||||
}
|
||||
} else {
|
||||
fellBackToSearchBased.current = true
|
||||
fetchSearchBasedCodeIntel(addRefs, addDefs)
|
||||
}
|
||||
setCodeIntelData({
|
||||
...(lsifData || EMPTY_CODE_INTEL_DATA),
|
||||
definitions: defs,
|
||||
references: refs,
|
||||
})
|
||||
},
|
||||
})
|
||||
const addDefs = (newDefs: Location[]): void => {
|
||||
defs.nodes = defs.nodes.combine(newDefs)
|
||||
}
|
||||
|
||||
const lsifData = result ? getLsifData({ data: result }) : undefined
|
||||
if (lsifData) {
|
||||
refs = lsifData.references
|
||||
defs = lsifData.definitions
|
||||
// If we've exhausted LSIF data and the flag is enabled, we add search-based data.
|
||||
if (refs.endCursor === null && shouldMixPreciseAndSearchBasedReferences()) {
|
||||
fetchSearchBasedReferences(addRefs)
|
||||
}
|
||||
// When no definitions are found, the hover tooltip falls back to a search based
|
||||
// search, regardless of the mixPreciseAndSearchBasedReferences setting.
|
||||
if (defs.nodes.locationsCount === 0) {
|
||||
fetchSearchBasedDefinitions(addDefs)
|
||||
}
|
||||
} else {
|
||||
fellBackToSearchBased.current = true
|
||||
fetchSearchBasedCodeIntel(addRefs, addDefs)
|
||||
}
|
||||
setCodeIntelData({
|
||||
...(lsifData || EMPTY_CODE_INTEL_DATA),
|
||||
definitions: defs,
|
||||
references: refs,
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const [fetchAdditionalReferences, additionalReferencesResult] = useLazyQuery<
|
||||
LoadAdditionalReferencesResult,
|
||||
LoadAdditionalReferencesVariables & ConnectionQueryArguments
|
||||
LoadAdditionalReferencesVariables
|
||||
>(LOAD_ADDITIONAL_REFERENCES_QUERY, {
|
||||
fetchPolicy: 'no-cache',
|
||||
onCompleted: result => {
|
||||
@ -168,7 +167,7 @@ export const useCodeIntel = ({
|
||||
|
||||
const [fetchAdditionalPrototypes, additionalPrototypesResult] = useLazyQuery<
|
||||
LoadAdditionalPrototypesResult,
|
||||
LoadAdditionalPrototypesVariables & ConnectionQueryArguments
|
||||
LoadAdditionalPrototypesVariables
|
||||
>(LOAD_ADDITIONAL_PROTOTYPES_QUERY, {
|
||||
fetchPolicy: 'no-cache',
|
||||
onCompleted: result => {
|
||||
@ -189,7 +188,7 @@ export const useCodeIntel = ({
|
||||
|
||||
const [fetchAdditionalImplementations, additionalImplementationsResult] = useLazyQuery<
|
||||
LoadAdditionalImplementationsResult,
|
||||
LoadAdditionalImplementationsVariables & ConnectionQueryArguments
|
||||
LoadAdditionalImplementationsVariables
|
||||
>(LOAD_ADDITIONAL_IMPLEMENTATIONS_QUERY, {
|
||||
fetchPolicy: 'no-cache',
|
||||
onCompleted: result => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
|
||||
import { logger } from '@sourcegraph/common'
|
||||
import { Button, Modal, Input, H3, Text, Alert, Link, ErrorAlert, Form } from '@sourcegraph/wildcard'
|
||||
import { Alert, Button, ErrorAlert, Form, H3, Input, Link, Modal, Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import { LoaderButton } from '../../../components/LoaderButton'
|
||||
import type { ExecutorSecretScope, Scalars } from '../../../graphql-operations'
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import React, { type FC, useCallback, useState, useEffect } from 'react'
|
||||
import React, { useCallback, useEffect, useState, type FC } from 'react'
|
||||
|
||||
import { dataOrThrowErrors } from '@sourcegraph/http-client'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Button, Container, Link, PageHeader } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { UseShowMorePaginationResult } from '../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import { useUrlSearchParamsForConnectionState } from '../../../components/FilteredConnection/hooks/connectionState'
|
||||
import {
|
||||
useShowMorePagination,
|
||||
type UseShowMorePaginationResult,
|
||||
} from '../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
ConnectionError,
|
||||
@ -14,9 +19,10 @@ import {
|
||||
SummaryContainer,
|
||||
} from '../../../components/FilteredConnection/ui'
|
||||
import {
|
||||
type ExecutorSecretFields,
|
||||
ExecutorSecretScope,
|
||||
type ExecutorSecretFields,
|
||||
type GlobalExecutorSecretsResult,
|
||||
type GlobalExecutorSecretsVariables,
|
||||
type OrgExecutorSecretsResult,
|
||||
type Scalars,
|
||||
type UserExecutorSecretsResult,
|
||||
@ -24,9 +30,9 @@ import {
|
||||
|
||||
import { AddSecretModal } from './AddSecretModal'
|
||||
import {
|
||||
globalExecutorSecretsConnectionFactory,
|
||||
userExecutorSecretsConnectionFactory,
|
||||
GLOBAL_EXECUTOR_SECRETS,
|
||||
orgExecutorSecretsConnectionFactory,
|
||||
userExecutorSecretsConnectionFactory,
|
||||
} from './backend'
|
||||
import { ExecutorSecretNode } from './ExecutorSecretNode'
|
||||
import { ExecutorSecretScopeSelector } from './ExecutorSecretScopeSelector'
|
||||
@ -38,9 +44,27 @@ export const GlobalExecutorSecretsListPage: FC<GlobalExecutorSecretsListPageProp
|
||||
() => props.telemetryRecorder.recordEvent('admin.executors.secretsList', 'view'),
|
||||
[props.telemetryRecorder]
|
||||
)
|
||||
|
||||
const connectionState = useUrlSearchParamsForConnectionState()
|
||||
const connectionLoader = useCallback(
|
||||
(scope: ExecutorSecretScope) => globalExecutorSecretsConnectionFactory(scope),
|
||||
[]
|
||||
(scope: ExecutorSecretScope) =>
|
||||
// Scope has to be injected dynamically.
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useShowMorePagination<GlobalExecutorSecretsResult, GlobalExecutorSecretsVariables, ExecutorSecretFields>({
|
||||
query: GLOBAL_EXECUTOR_SECRETS,
|
||||
variables: {
|
||||
scope,
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'network-only',
|
||||
},
|
||||
getConnection: result => {
|
||||
const { executorSecrets } = dataOrThrowErrors(result)
|
||||
return executorSecrets
|
||||
},
|
||||
state: connectionState,
|
||||
}),
|
||||
[connectionState]
|
||||
)
|
||||
return (
|
||||
<ExecutorSecretsListPage
|
||||
@ -217,7 +241,6 @@ const ExecutorSecretsListPage: FC<ExecutorSecretsListPageProps> = ({
|
||||
<SummaryContainer className="mt-2">
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={15}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="executor secret"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
|
||||
import { Button, Modal, H3, Text } from '@sourcegraph/wildcard'
|
||||
import { Button, H3, Modal, Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import {
|
||||
ConnectionContainer,
|
||||
@ -46,7 +46,6 @@ export const SecretAccessLogsModal: React.FunctionComponent<React.PropsWithChild
|
||||
<SummaryContainer className="mt-2">
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={15}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="access log"
|
||||
|
||||
@ -7,24 +7,22 @@ import {
|
||||
type UseShowMorePaginationResult,
|
||||
} from '../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import type {
|
||||
ExecutorSecretFields,
|
||||
Scalars,
|
||||
UserExecutorSecretsResult,
|
||||
UserExecutorSecretsVariables,
|
||||
ExecutorSecretScope,
|
||||
DeleteExecutorSecretResult,
|
||||
DeleteExecutorSecretVariables,
|
||||
GlobalExecutorSecretsResult,
|
||||
GlobalExecutorSecretsVariables,
|
||||
CreateExecutorSecretResult,
|
||||
CreateExecutorSecretVariables,
|
||||
UpdateExecutorSecretResult,
|
||||
UpdateExecutorSecretVariables,
|
||||
OrgExecutorSecretsResult,
|
||||
OrgExecutorSecretsVariables,
|
||||
DeleteExecutorSecretResult,
|
||||
DeleteExecutorSecretVariables,
|
||||
ExecutorSecretAccessLogFields,
|
||||
ExecutorSecretAccessLogsResult,
|
||||
ExecutorSecretAccessLogsVariables,
|
||||
ExecutorSecretFields,
|
||||
ExecutorSecretScope,
|
||||
OrgExecutorSecretsResult,
|
||||
OrgExecutorSecretsVariables,
|
||||
Scalars,
|
||||
UpdateExecutorSecretResult,
|
||||
UpdateExecutorSecretVariables,
|
||||
UserExecutorSecretsResult,
|
||||
UserExecutorSecretsVariables,
|
||||
} from '../../../graphql-operations'
|
||||
|
||||
const EXECUTOR_SECRET_FIELDS = gql`
|
||||
@ -127,8 +125,6 @@ export const userExecutorSecretsConnectionFactory = (
|
||||
variables: {
|
||||
user,
|
||||
scope,
|
||||
after: null,
|
||||
first: 15,
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'network-only',
|
||||
@ -173,8 +169,6 @@ export const orgExecutorSecretsConnectionFactory = (
|
||||
variables: {
|
||||
org,
|
||||
scope,
|
||||
after: null,
|
||||
first: 15,
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'network-only',
|
||||
@ -203,29 +197,6 @@ export const GLOBAL_EXECUTOR_SECRETS = gql`
|
||||
${EXECUTOR_SECRET_CONNECTION_FIELDS}
|
||||
`
|
||||
|
||||
export const globalExecutorSecretsConnectionFactory = (
|
||||
scope: ExecutorSecretScope
|
||||
): UseShowMorePaginationResult<GlobalExecutorSecretsResult, ExecutorSecretFields> =>
|
||||
// Scope has to be injected dynamically.
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useShowMorePagination<GlobalExecutorSecretsResult, GlobalExecutorSecretsVariables, ExecutorSecretFields>({
|
||||
query: GLOBAL_EXECUTOR_SECRETS,
|
||||
variables: {
|
||||
after: null,
|
||||
first: 15,
|
||||
scope,
|
||||
},
|
||||
options: {
|
||||
useURL: true,
|
||||
fetchPolicy: 'network-only',
|
||||
},
|
||||
getConnection: result => {
|
||||
const { executorSecrets } = dataOrThrowErrors(result)
|
||||
|
||||
return executorSecrets
|
||||
},
|
||||
})
|
||||
|
||||
export const EXECUTOR_SECRET_ACCESS_LOGS = gql`
|
||||
query ExecutorSecretAccessLogs($secret: ID!, $first: Int, $after: String) {
|
||||
node(id: $secret) {
|
||||
@ -273,8 +244,6 @@ export const useExecutorSecretAccessLogsConnection = (
|
||||
query: EXECUTOR_SECRET_ACCESS_LOGS,
|
||||
variables: {
|
||||
secret,
|
||||
first: 15,
|
||||
after: null,
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'network-only',
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { type ChangeEvent, type FC, useState, useEffect } from 'react'
|
||||
import { type ChangeEvent, type FC, useEffect, useState } from 'react'
|
||||
|
||||
import { mdiMapSearch } from '@mdi/js'
|
||||
|
||||
import { BackfillQueueOrderBy, InsightQueueItemState } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { type TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import {
|
||||
Container,
|
||||
ErrorAlert,
|
||||
@ -47,7 +47,7 @@ export const CodeInsightsJobs: FC<Props> = ({ telemetryRecorder }) => {
|
||||
query: GET_CODE_INSIGHTS_JOBS,
|
||||
variables: { orderBy, states: selectedFilters, search },
|
||||
getConnection: ({ data }) => data?.insightAdminBackfillQueue,
|
||||
options: { pollInterval: 10000, pageSize: 15 },
|
||||
options: { pollInterval: 10000 },
|
||||
})
|
||||
|
||||
const handleJobSelect = (event: ChangeEvent<HTMLInputElement>, jobId: string): void => {
|
||||
|
||||
@ -7,14 +7,14 @@ import { dataOrThrowErrors, getDocumentNode, gql } from '@sourcegraph/http-clien
|
||||
import type { Connection } from '../../../../../../../components/FilteredConnection'
|
||||
import { useShowMorePagination } from '../../../../../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
GroupByField,
|
||||
type AssignableInsight,
|
||||
type DashboardInsights,
|
||||
type FindInsightsBySearchTermResult,
|
||||
type FindInsightsBySearchTermVariables,
|
||||
GroupByField,
|
||||
} from '../../../../../../../graphql-operations'
|
||||
|
||||
import { type DashboardInsight, type InsightSuggestion, InsightType } from './types'
|
||||
import { InsightType, type DashboardInsight, type InsightSuggestion } from './types'
|
||||
|
||||
const SYNC_DASHBOARD_INSIGHTS = gql`
|
||||
fragment DashboardInsights on InsightsDashboard {
|
||||
@ -108,7 +108,7 @@ export function useInsightSuggestions(input: UseInsightSuggestionsInput): UseIns
|
||||
AssignableInsight | null
|
||||
>({
|
||||
query: GET_INSIGHTS_BY_SEARCH_TERM,
|
||||
variables: { first: 20, after: null, search, excludeIds },
|
||||
variables: { search, excludeIds },
|
||||
getConnection: result => {
|
||||
const { insightViews } = dataOrThrowErrors(result)
|
||||
|
||||
|
||||
@ -155,7 +155,6 @@ export const SearchJobsPage: FC<SearchJobsPageProps> = props => {
|
||||
options: {
|
||||
pollInterval: 5000,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
pageSize: 15,
|
||||
},
|
||||
getConnection: result => {
|
||||
const data = dataOrThrowErrors(result)
|
||||
|
||||
@ -41,7 +41,7 @@ export const SearchContextsList: React.FunctionComponent<SearchContextsListProps
|
||||
setAlert,
|
||||
}) => {
|
||||
const queryConnection = useCallback(
|
||||
(args: Partial<ListSearchContextsVariables>) => {
|
||||
(args: Omit<Partial<ListSearchContextsVariables>, 'first'> & { first?: number | null }) => {
|
||||
const { namespace, orderBy, descending } = args as {
|
||||
namespace: string | undefined
|
||||
orderBy: SearchContextsOrderBy
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { type Observable, Subject, Subscription } from 'rxjs'
|
||||
import { Subject, Subscription, type Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
|
||||
import { createAggregateError } from '@sourcegraph/common'
|
||||
import { gql } from '@sourcegraph/http-client'
|
||||
import type { Scalars } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
|
||||
import { Button, Link, H2, Text } from '@sourcegraph/wildcard'
|
||||
import { Button, H2, Link, Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import { requestGraphQL } from '../../backend/graphql'
|
||||
import { FilteredConnection } from '../../components/FilteredConnection'
|
||||
@ -20,8 +20,8 @@ import type {
|
||||
} from '../../graphql-operations'
|
||||
import {
|
||||
ExternalAccountNode,
|
||||
type ExternalAccountNodeProps,
|
||||
externalAccountsConnectionFragment,
|
||||
type ExternalAccountNodeProps,
|
||||
} from '../user/settings/ExternalAccountNode'
|
||||
|
||||
interface Props {}
|
||||
@ -83,7 +83,7 @@ export class SiteAdminExternalAccountsPage extends React.Component<Props> {
|
||||
|
||||
private queryExternalAccounts = (
|
||||
args: {
|
||||
first?: number
|
||||
first?: number | null
|
||||
} & FilterParameters
|
||||
): Observable<ExternalAccountsConnectionFields> =>
|
||||
requestGraphQL<ExternalAccountsResult, ExternalAccountsVariables>(
|
||||
|
||||
@ -8,12 +8,12 @@ import { Container, PageHeader } from '@sourcegraph/wildcard'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
ConnectionError,
|
||||
ConnectionLoading,
|
||||
ConnectionForm,
|
||||
ConnectionList,
|
||||
SummaryContainer,
|
||||
ConnectionLoading,
|
||||
ConnectionSummary,
|
||||
ShowMoreButton,
|
||||
ConnectionForm,
|
||||
SummaryContainer,
|
||||
} from '../../../../components/FilteredConnection/ui'
|
||||
import { PageTitle } from '../../../../components/PageTitle'
|
||||
|
||||
@ -36,10 +36,7 @@ export const SiteAdminLicenseKeyLookupPage: React.FunctionComponent<React.PropsW
|
||||
|
||||
const [search, setSearch] = useState<string>(searchParams.get(SEARCH_PARAM_KEY) ?? '')
|
||||
|
||||
const { loading, hasNextPage, fetchMore, refetchAll, connection, error } = useQueryProductLicensesConnection(
|
||||
search,
|
||||
20
|
||||
)
|
||||
const { loading, hasNextPage, fetchMore, refetchAll, connection, error } = useQueryProductLicensesConnection(search)
|
||||
|
||||
useEffect(() => {
|
||||
const query = search?.trim() ?? ''
|
||||
@ -92,7 +89,6 @@ export const SiteAdminLicenseKeyLookupPage: React.FunctionComponent<React.PropsW
|
||||
{connection && (
|
||||
<SummaryContainer className="mt-2 mb-0">
|
||||
<ConnectionSummary
|
||||
first={15}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="product license"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { mdiPlus } from '@mdi/js'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
@ -7,7 +7,7 @@ import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
|
||||
import { logger } from '@sourcegraph/common'
|
||||
import { useMutation, useQuery } from '@sourcegraph/http-client'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Button, LoadingSpinner, Link, Icon, ErrorAlert, PageHeader, Container, H3, Text } from '@sourcegraph/wildcard'
|
||||
import { Button, Container, ErrorAlert, H3, Icon, Link, LoadingSpinner, PageHeader, Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import {
|
||||
ConnectionContainer,
|
||||
@ -21,10 +21,10 @@ import {
|
||||
import { PageTitle } from '../../../../components/PageTitle'
|
||||
import { useScrollToLocationHash } from '../../../../components/useScrollToLocationHash'
|
||||
import type {
|
||||
DotComProductSubscriptionResult,
|
||||
DotComProductSubscriptionVariables,
|
||||
ArchiveProductSubscriptionResult,
|
||||
ArchiveProductSubscriptionVariables,
|
||||
DotComProductSubscriptionResult,
|
||||
DotComProductSubscriptionVariables,
|
||||
} from '../../../../graphql-operations'
|
||||
import { AccountName } from '../../../dotcom/productSubscriptions/AccountName'
|
||||
import { ProductSubscriptionLabel } from '../../../dotcom/productSubscriptions/ProductSubscriptionLabel'
|
||||
@ -39,7 +39,7 @@ import { CodyServicesSection } from './CodyServicesSection'
|
||||
import type { EnterprisePortalEnvironment } from './enterpriseportal'
|
||||
import { SiteAdminGenerateProductLicenseForSubscriptionForm } from './SiteAdminGenerateProductLicenseForSubscriptionForm'
|
||||
import { SiteAdminProductLicenseNode } from './SiteAdminProductLicenseNode'
|
||||
import { accessTokenPath, errorForPath, enterprisePortalID } from './utils'
|
||||
import { accessTokenPath, enterprisePortalID, errorForPath } from './utils'
|
||||
|
||||
interface Props extends TelemetryV2Props {}
|
||||
|
||||
@ -267,10 +267,8 @@ const ProductSubscriptionLicensesConnection: React.FunctionComponent<ProductSubs
|
||||
toggleShowGenerate,
|
||||
telemetryRecorder,
|
||||
}) => {
|
||||
const { loading, hasNextPage, fetchMore, refetchAll, connection, error } = useProductSubscriptionLicensesConnection(
|
||||
subscriptionUUID,
|
||||
20
|
||||
)
|
||||
const { loading, hasNextPage, fetchMore, refetchAll, connection, error } =
|
||||
useProductSubscriptionLicensesConnection(subscriptionUUID)
|
||||
|
||||
useEffect(() => {
|
||||
setRefetch(refetchAll)
|
||||
@ -304,7 +302,6 @@ const ProductSubscriptionLicensesConnection: React.FunctionComponent<ProductSubs
|
||||
{connection && (
|
||||
<SummaryContainer centered={true}>
|
||||
<ConnectionSummary
|
||||
first={15}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="product license"
|
||||
|
||||
@ -5,17 +5,17 @@ import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
|
||||
import { queryGraphQL } from '../../../../backend/graphql'
|
||||
import {
|
||||
type UseShowMorePaginationResult,
|
||||
useShowMorePagination,
|
||||
type UseShowMorePaginationResult,
|
||||
} from '../../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import type {
|
||||
ProductLicensesResult,
|
||||
DotComProductLicensesResult,
|
||||
DotComProductLicensesVariables,
|
||||
ProductLicenseFields,
|
||||
ProductLicensesResult,
|
||||
ProductLicensesVariables,
|
||||
ProductSubscriptionsDotComResult,
|
||||
ProductSubscriptionsDotComVariables,
|
||||
DotComProductLicensesResult,
|
||||
DotComProductLicensesVariables,
|
||||
} from '../../../../graphql-operations'
|
||||
|
||||
const siteAdminProductSubscriptionFragment = gql`
|
||||
@ -219,14 +219,11 @@ export const PRODUCT_LICENSES = gql`
|
||||
`
|
||||
|
||||
export const useProductSubscriptionLicensesConnection = (
|
||||
subscriptionUUID: string,
|
||||
first: number
|
||||
subscriptionUUID: string
|
||||
): UseShowMorePaginationResult<ProductLicensesResult, ProductLicenseFields> =>
|
||||
useShowMorePagination<ProductLicensesResult, ProductLicensesVariables, ProductLicenseFields>({
|
||||
query: PRODUCT_LICENSES,
|
||||
variables: {
|
||||
first: first ?? 20,
|
||||
after: null,
|
||||
subscriptionUUID,
|
||||
},
|
||||
getConnection: result => {
|
||||
@ -239,7 +236,7 @@ export const useProductSubscriptionLicensesConnection = (
|
||||
})
|
||||
|
||||
export function queryProductSubscriptions(args: {
|
||||
first?: number
|
||||
first?: number | null
|
||||
query?: string
|
||||
}): Observable<ProductSubscriptionsDotComResult['dotcom']['productSubscriptions']> {
|
||||
return queryGraphQL<ProductSubscriptionsDotComResult>(
|
||||
@ -287,14 +284,11 @@ const QUERY_PRODUCT_LICENSES = gql`
|
||||
`
|
||||
|
||||
export const useQueryProductLicensesConnection = (
|
||||
licenseKeySubstring: string,
|
||||
first: number
|
||||
licenseKeySubstring: string
|
||||
): UseShowMorePaginationResult<DotComProductLicensesResult, ProductLicenseFields> =>
|
||||
useShowMorePagination<DotComProductLicensesResult, DotComProductLicensesVariables, ProductLicenseFields>({
|
||||
query: QUERY_PRODUCT_LICENSES,
|
||||
variables: {
|
||||
first: first ?? 20,
|
||||
after: null,
|
||||
licenseKeySubstring,
|
||||
},
|
||||
getConnection: result => {
|
||||
|
||||
@ -6,8 +6,8 @@ import { map } from 'rxjs/operators'
|
||||
|
||||
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Container, PageHeader, Link, Code } from '@sourcegraph/wildcard'
|
||||
import { type TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Code, Container, Link, PageHeader } from '@sourcegraph/wildcard'
|
||||
|
||||
import { requestGraphQL } from '../../../backend/graphql'
|
||||
import { FilteredConnection } from '../../../components/FilteredConnection'
|
||||
@ -86,7 +86,7 @@ export const UserEventLogsPageContent: React.FunctionComponent<
|
||||
}, [telemetryRecorder])
|
||||
|
||||
const queryUserEventLogs = useCallback(
|
||||
(args: { first?: number }): Observable<UserEventLogsConnectionFields> =>
|
||||
(args: { first?: number | null }): Observable<UserEventLogsConnectionFields> =>
|
||||
requestGraphQL<UserEventLogsResult, UserEventLogsVariables>(
|
||||
gql`
|
||||
query UserEventLogs($user: ID!, $first: Int) {
|
||||
|
||||
@ -696,8 +696,7 @@ describe('Batches', () => {
|
||||
await driver.page.waitForSelector('.test-batch-change-details-page', { visible: true })
|
||||
assert.strictEqual(
|
||||
await driver.page.evaluate(() => window.location.href),
|
||||
// We now have 1 in the cache, so we'll have a starting number visible that gets set in the URL.
|
||||
driver.sourcegraphBaseUrl + namespaceURL + '/batch-changes/test-batch-change?visible=1'
|
||||
driver.sourcegraphBaseUrl + namespaceURL + '/batch-changes/test-batch-change'
|
||||
)
|
||||
|
||||
// Delete the closed batch change.
|
||||
|
||||
@ -85,7 +85,7 @@ describe('Repository', () => {
|
||||
const shortRepositoryName = 'sourcegraph/jsonrpc2'
|
||||
const repositoryName = `github.com/${shortRepositoryName}`
|
||||
const repositorySourcegraphUrl = `/${repositoryName}`
|
||||
const commitUrl = `${repositorySourcegraphUrl}/-/commit/15c2290dcb37731cc4ee5a2a1c1e5a25b4c28f81?visible=1`
|
||||
const commitUrl = `${repositorySourcegraphUrl}/-/commit/15c2290dcb37731cc4ee5a2a1c1e5a25b4c28f81?first=1`
|
||||
const clickedFileName = 'async.go'
|
||||
const clickedCommit = ''
|
||||
const fileEntries = ['jsonrpc2.go', clickedFileName]
|
||||
@ -1107,7 +1107,7 @@ describe('Repository', () => {
|
||||
})
|
||||
|
||||
describe('Compare page', () => {
|
||||
const repositorySourcegraphUrl = `/${repositoryName}/-/compare/main...bl/readme?visible=1`
|
||||
const repositorySourcegraphUrl = `/${repositoryName}/-/compare/main...bl/readme`
|
||||
it('should render correctly compare page, including diff view', async () => {
|
||||
testContext.overrideGraphQL({
|
||||
...commonWebGraphQlResults,
|
||||
|
||||
@ -56,7 +56,7 @@ export function fetchAllSurveyResponses(): Observable<FetchSurveyResponsesResult
|
||||
*/
|
||||
export function fetchAllUsersWithSurveyResponses(args: {
|
||||
activePeriod?: UserActivePeriod
|
||||
first?: number
|
||||
first?: number | null
|
||||
query?: string
|
||||
}): Observable<FetchAllUsersWithSurveyResponsesResult['users']> {
|
||||
return requestGraphQL<FetchAllUsersWithSurveyResponsesResult, FetchAllUsersWithSurveyResponsesVariables>(
|
||||
|
||||
@ -43,7 +43,7 @@ export const NotebooksList: FC<NotebooksListProps> = ({
|
||||
}, [logEventName, telemetryService])
|
||||
|
||||
const queryConnection = useCallback(
|
||||
(args: Partial<ListNotebooksVariables>) => {
|
||||
(args: Omit<Partial<ListNotebooksVariables>, 'first'> & { first?: number | null }) => {
|
||||
const { orderBy, descending } = args as {
|
||||
orderBy: NotebooksOrderBy | undefined
|
||||
descending: boolean | undefined
|
||||
|
||||
@ -5,15 +5,15 @@ import type { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
|
||||
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
|
||||
import { createAggregateError, numberWithCommas, memoizeObservable } from '@sourcegraph/common'
|
||||
import { createAggregateError, memoizeObservable, numberWithCommas } from '@sourcegraph/common'
|
||||
import { gql } from '@sourcegraph/http-client'
|
||||
import { Badge, Icon, LinkOrSpan } from '@sourcegraph/wildcard'
|
||||
|
||||
import { requestGraphQL } from '../backend/graphql'
|
||||
import {
|
||||
GitRefType,
|
||||
type GitRefConnectionFields,
|
||||
type GitRefFields,
|
||||
GitRefType,
|
||||
type RepositoryGitRefsResult,
|
||||
type RepositoryGitRefsVariables,
|
||||
type Scalars,
|
||||
@ -167,7 +167,7 @@ export const REPOSITORY_GIT_REFS = gql`
|
||||
export const queryGitReferences = memoizeObservable(
|
||||
(args: {
|
||||
repo: Scalars['ID']
|
||||
first?: number
|
||||
first?: number | null
|
||||
query?: string
|
||||
type: GitRefType
|
||||
withBehindAhead?: boolean
|
||||
|
||||
@ -7,7 +7,7 @@ import { useLocation } from 'react-router-dom'
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { FileSpec, RevisionSpec } from '@sourcegraph/shared/src/util/url'
|
||||
import { Icon, Link, ErrorAlert } from '@sourcegraph/wildcard'
|
||||
import { ErrorAlert, Icon, Link } from '@sourcegraph/wildcard'
|
||||
|
||||
import { useShowMorePagination } from '../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
@ -66,8 +66,6 @@ export const RepoRevisionSidebarCommits: FC<Props> = props => {
|
||||
>({
|
||||
query: FETCH_COMMITS,
|
||||
variables: {
|
||||
afterCursor: null,
|
||||
first: props.defaultPageSize || 100,
|
||||
query: '',
|
||||
repo: props.repoID,
|
||||
revision: props.revision || '',
|
||||
@ -95,6 +93,7 @@ export const RepoRevisionSidebarCommits: FC<Props> = props => {
|
||||
// will ensure that the pagination works correctly.
|
||||
useAlternateAfterCursor: true,
|
||||
fetchPolicy: 'cache-first',
|
||||
pageSize: props.defaultPageSize,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { MockedResponse } from '@apollo/client/testing'
|
||||
import { cleanup, fireEvent } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, within } from '@testing-library/react'
|
||||
import delay from 'delay'
|
||||
import { escapeRegExp } from 'lodash'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
@ -29,49 +29,79 @@ const sidebarProps: RepoRevisionSidebarSymbolsProps = {
|
||||
onHandleSymbolClick: () => {},
|
||||
}
|
||||
|
||||
const symbolsMock: MockedResponse<SymbolsResult> = {
|
||||
request: {
|
||||
query: getDocumentNode(SYMBOLS_QUERY),
|
||||
variables: {
|
||||
query: '',
|
||||
first: 100,
|
||||
repo: sidebarProps.repoID,
|
||||
revision: sidebarProps.revision,
|
||||
includePatterns: ['^' + escapeRegExp(sidebarProps.activePath)],
|
||||
const symbolsMocks: MockedResponse<SymbolsResult>[] = [
|
||||
{
|
||||
request: {
|
||||
query: getDocumentNode(SYMBOLS_QUERY),
|
||||
variables: {
|
||||
query: '',
|
||||
first: 100,
|
||||
repo: sidebarProps.repoID,
|
||||
revision: sidebarProps.revision,
|
||||
includePatterns: ['^' + escapeRegExp(sidebarProps.activePath)],
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
node: {
|
||||
__typename: 'Repository',
|
||||
commit: {
|
||||
symbols: {
|
||||
__typename: 'SymbolConnection',
|
||||
nodes: [
|
||||
{
|
||||
__typename: 'Symbol',
|
||||
kind: SymbolKind.CONSTANT,
|
||||
language: 'TypeScript',
|
||||
name: 'firstSymbol',
|
||||
url: `${location.pathname}?L13:14`,
|
||||
containerName: null,
|
||||
location: {
|
||||
resource: {
|
||||
path: 'src/index.js',
|
||||
result: {
|
||||
data: {
|
||||
node: {
|
||||
__typename: 'Repository',
|
||||
commit: {
|
||||
symbols: {
|
||||
__typename: 'SymbolConnection',
|
||||
nodes: [
|
||||
{
|
||||
__typename: 'Symbol',
|
||||
kind: SymbolKind.CONSTANT,
|
||||
language: 'TypeScript',
|
||||
name: 'firstSymbol',
|
||||
url: `${location.pathname}?L13:14`,
|
||||
containerName: null,
|
||||
location: {
|
||||
resource: {
|
||||
path: 'src/index.js',
|
||||
},
|
||||
range: null,
|
||||
},
|
||||
range: null,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
{
|
||||
request: {
|
||||
query: getDocumentNode(SYMBOLS_QUERY),
|
||||
variables: {
|
||||
query: 'some query',
|
||||
first: 100,
|
||||
repo: sidebarProps.repoID,
|
||||
revision: sidebarProps.revision,
|
||||
includePatterns: ['^' + escapeRegExp(sidebarProps.activePath)],
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
node: {
|
||||
__typename: 'Repository',
|
||||
commit: {
|
||||
symbols: {
|
||||
__typename: 'SymbolConnection',
|
||||
nodes: [],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
describe('RepoRevisionSidebarSymbols', () => {
|
||||
let renderResult: RenderWithBrandedContextResult
|
||||
@ -79,7 +109,7 @@ describe('RepoRevisionSidebarSymbols', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
renderResult = renderWithBrandedContext(
|
||||
<MockedTestProvider mocks={[symbolsMock]} addTypename={true}>
|
||||
<MockedTestProvider mocks={symbolsMocks} addTypename={true}>
|
||||
<RepoRevisionSidebarSymbols {...sidebarProps} />
|
||||
</MockedTestProvider>,
|
||||
{ route }
|
||||
@ -108,8 +138,14 @@ describe('RepoRevisionSidebarSymbols', () => {
|
||||
expect(symbol).toBeVisible()
|
||||
})
|
||||
|
||||
it('renders summary correctly', () => {
|
||||
expect(renderResult.getByText('1 symbol total')).toBeVisible()
|
||||
it('renders no-query-matches correctly', async () => {
|
||||
const searchInput = within(renderResult.container).getByRole('searchbox')
|
||||
fireEvent.change(searchInput, { target: { value: 'some query' } })
|
||||
|
||||
await waitForInputDebounce()
|
||||
await waitForNextApolloResponse()
|
||||
|
||||
expect(renderResult.getByTestId('summary')).toHaveTextContent('No symbols matching some query')
|
||||
})
|
||||
|
||||
it('clicking symbol updates route', async () => {
|
||||
@ -126,3 +162,5 @@ describe('RepoRevisionSidebarSymbols', () => {
|
||||
expect(renderResult.locationRef.current?.search).toEqual('?L13:14')
|
||||
})
|
||||
})
|
||||
|
||||
const waitForInputDebounce = () => act(() => new Promise(resolve => setTimeout(resolve, 200)))
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
import React, { useState, useMemo, Suspense } from 'react'
|
||||
import React, { Suspense, useMemo, useState } from 'react'
|
||||
|
||||
import classNames from 'classnames'
|
||||
import { escapeRegExp, groupBy } from 'lodash'
|
||||
|
||||
import { logger } from '@sourcegraph/common'
|
||||
import { gql, dataOrThrowErrors } from '@sourcegraph/http-client'
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
import type { RevisionSpec } from '@sourcegraph/shared/src/util/url'
|
||||
import { Alert, useDebounce, ErrorMessage } from '@sourcegraph/wildcard'
|
||||
import { Alert, ErrorMessage, useDebounce } from '@sourcegraph/wildcard'
|
||||
|
||||
import { useShowMorePagination } from '../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
ConnectionForm,
|
||||
ConnectionContainer,
|
||||
ConnectionForm,
|
||||
ConnectionLoading,
|
||||
ConnectionSummary,
|
||||
SummaryContainer,
|
||||
ShowMoreButton,
|
||||
SummaryContainer,
|
||||
} from '../components/FilteredConnection/ui'
|
||||
import type { Scalars, SymbolNodeFields, SymbolsResult, SymbolsVariables } from '../graphql-operations'
|
||||
|
||||
@ -101,7 +101,6 @@ export const RepoRevisionSidebarSymbols: React.FunctionComponent<
|
||||
query: SYMBOLS_QUERY,
|
||||
variables: {
|
||||
query,
|
||||
first: BATCH_COUNT,
|
||||
repo: repoID,
|
||||
revision,
|
||||
// `includePatterns` expects regexes, so first escape the path.
|
||||
@ -124,13 +123,13 @@ export const RepoRevisionSidebarSymbols: React.FunctionComponent<
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'cache-first',
|
||||
pageSize: BATCH_COUNT,
|
||||
},
|
||||
})
|
||||
|
||||
const summary = connection && (
|
||||
<ConnectionSummary
|
||||
connection={connection}
|
||||
first={BATCH_COUNT}
|
||||
noun="symbol"
|
||||
pluralNoun="symbols"
|
||||
hasNextPage={hasNextPage}
|
||||
|
||||
@ -4,7 +4,7 @@ import { getDocumentNode } from '@sourcegraph/http-client'
|
||||
|
||||
import type { RepositoriesForPopoverResult, RepositoryPopoverFields } from '../../graphql-operations'
|
||||
|
||||
import { REPOSITORIES_FOR_POPOVER, BATCH_COUNT } from './RepositoriesPopover'
|
||||
import { BATCH_COUNT, REPOSITORIES_FOR_POPOVER } from './RepositoriesPopover'
|
||||
|
||||
interface GenerateRepositoryNodesParameters {
|
||||
count: number
|
||||
@ -29,7 +29,6 @@ const repositoriesMock: MockedResponse<RepositoriesForPopoverResult> = {
|
||||
variables: {
|
||||
query: '',
|
||||
first: BATCH_COUNT,
|
||||
after: null,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
@ -73,7 +72,6 @@ const filteredRepositoriesMock: MockedResponse<RepositoriesForPopoverResult> = {
|
||||
variables: {
|
||||
query: 'some query',
|
||||
first: BATCH_COUNT,
|
||||
after: null,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
@ -95,7 +93,6 @@ const additionalFilteredRepositoriesMock: MockedResponse<RepositoriesForPopoverR
|
||||
variables: {
|
||||
query: 'some other query',
|
||||
first: BATCH_COUNT,
|
||||
after: null,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
|
||||
@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react'
|
||||
import { createAggregateError } from '@sourcegraph/common'
|
||||
import { gql } from '@sourcegraph/http-client'
|
||||
import type { Scalars } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { type TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
|
||||
import { useDebounce } from '@sourcegraph/wildcard'
|
||||
@ -82,7 +82,7 @@ export const RepositoriesPopover: React.FunctionComponent<React.PropsWithChildre
|
||||
RepositoryPopoverFields
|
||||
>({
|
||||
query: REPOSITORIES_FOR_POPOVER,
|
||||
variables: { first: BATCH_COUNT, after: null, query },
|
||||
variables: { query },
|
||||
getConnection: ({ data, errors }) => {
|
||||
if (!data?.repositories) {
|
||||
throw createAggregateError(errors)
|
||||
@ -90,6 +90,7 @@ export const RepositoriesPopover: React.FunctionComponent<React.PropsWithChildre
|
||||
return data.repositories
|
||||
},
|
||||
options: {
|
||||
pageSize: BATCH_COUNT,
|
||||
fetchPolicy: 'cache-first',
|
||||
},
|
||||
})
|
||||
@ -97,7 +98,6 @@ export const RepositoriesPopover: React.FunctionComponent<React.PropsWithChildre
|
||||
const summary = connection && (
|
||||
<ConnectionSummary
|
||||
connection={connection}
|
||||
first={BATCH_COUNT}
|
||||
noun="repository"
|
||||
pluralNoun="repositories"
|
||||
hasNextPage={hasNextPage}
|
||||
|
||||
@ -198,7 +198,6 @@ describe('RevisionsPopover', () => {
|
||||
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', () => {
|
||||
|
||||
@ -144,7 +144,6 @@ export const RevisionsPopoverCommits: React.FunctionComponent<
|
||||
query: REPOSITORY_GIT_COMMIT,
|
||||
variables: {
|
||||
query,
|
||||
first: BATCH_COUNT,
|
||||
repo,
|
||||
revision: currentRev || defaultBranch,
|
||||
},
|
||||
@ -175,13 +174,13 @@ export const RevisionsPopoverCommits: React.FunctionComponent<
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'cache-first',
|
||||
pageSize: BATCH_COUNT,
|
||||
},
|
||||
})
|
||||
|
||||
const summary = response.connection && (
|
||||
<ConnectionSummary
|
||||
connection={response.connection}
|
||||
first={BATCH_COUNT}
|
||||
noun={noun}
|
||||
pluralNoun={pluralNoun}
|
||||
hasNextPage={response.hasNextPage}
|
||||
|
||||
@ -11,7 +11,7 @@ import { useDebounce } from '@sourcegraph/wildcard'
|
||||
import { useShowMorePagination } from '../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import { ConnectionSummary } from '../../components/FilteredConnection/ui'
|
||||
import type { GitRefFields, RepositoryGitRefsResult, RepositoryGitRefsVariables } from '../../graphql-operations'
|
||||
import { type GitReferenceNodeProps, REPOSITORY_GIT_REFS } from '../GitReference'
|
||||
import { REPOSITORY_GIT_REFS, type GitReferenceNodeProps } from '../GitReference'
|
||||
|
||||
import { ConnectionPopoverGitReferenceNode } from './components'
|
||||
import { RevisionsPopoverTab } from './RevisionsPopoverTab'
|
||||
@ -150,7 +150,6 @@ export const RevisionsPopoverReferences: React.FunctionComponent<
|
||||
query: REPOSITORY_GIT_REFS,
|
||||
variables: {
|
||||
query,
|
||||
first: BATCH_COUNT,
|
||||
repo,
|
||||
type,
|
||||
withBehindAhead: false,
|
||||
@ -163,6 +162,7 @@ export const RevisionsPopoverReferences: React.FunctionComponent<
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'cache-first',
|
||||
pageSize: BATCH_COUNT,
|
||||
},
|
||||
})
|
||||
|
||||
@ -170,7 +170,6 @@ export const RevisionsPopoverReferences: React.FunctionComponent<
|
||||
<ConnectionSummary
|
||||
emptyElement={showSpeculativeResults ? <></> : undefined}
|
||||
connection={response.connection}
|
||||
first={BATCH_COUNT}
|
||||
noun={noun}
|
||||
pluralNoun={pluralNoun}
|
||||
hasNextPage={response.hasNextPage}
|
||||
|
||||
@ -39,7 +39,7 @@ function fetchBlobCacheKey(options: FetchBlobOptions): string {
|
||||
|
||||
return `${makeRepoGitURI(
|
||||
options
|
||||
)}?disableTimeout=${disableTimeout}&=${format}&snap=${scipSnapshot}&visible=${visibleIndexID}`
|
||||
)}?disableTimeout=${disableTimeout}&=${format}&snap=${scipSnapshot}&first=${visibleIndexID}`
|
||||
}
|
||||
|
||||
interface FetchBlobOptions {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { type FC, useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, type FC } from 'react'
|
||||
|
||||
import { capitalize } from 'lodash'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
@ -6,13 +6,14 @@ import { useLocation } from 'react-router-dom'
|
||||
import { basename, pluralize } from '@sourcegraph/common'
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
import { displayRepoName } from '@sourcegraph/shared/src/components/RepoLink'
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
|
||||
import type { RevisionSpec } from '@sourcegraph/shared/src/util/url'
|
||||
import { Code, Heading, ErrorAlert } from '@sourcegraph/wildcard'
|
||||
import { Code, ErrorAlert, Heading } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { BreadcrumbSetters } from '../../components/Breadcrumbs'
|
||||
import { useUrlSearchParamsForConnectionState } from '../../components/FilteredConnection/hooks/connectionState'
|
||||
import { useShowMorePagination } from '../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
@ -24,11 +25,11 @@ import {
|
||||
} from '../../components/FilteredConnection/ui'
|
||||
import { PageTitle } from '../../components/PageTitle'
|
||||
import {
|
||||
RepositoryType,
|
||||
type GitCommitFields,
|
||||
type RepositoryFields,
|
||||
type RepositoryGitCommitsResult,
|
||||
type RepositoryGitCommitsVariables,
|
||||
RepositoryType,
|
||||
} from '../../graphql-operations'
|
||||
import { parseBrowserRepoURL } from '../../util/url'
|
||||
import { externalLinkFieldsFragment } from '../backend'
|
||||
@ -97,8 +98,6 @@ export const gitCommitFragment = gql`
|
||||
${externalLinkFieldsFragment}
|
||||
`
|
||||
|
||||
const REPOSITORY_GIT_COMMITS_PER_PAGE = 20
|
||||
|
||||
export const REPOSITORY_GIT_COMMITS_QUERY = gql`
|
||||
query RepositoryGitCommits($repo: ID!, $revspec: String!, $first: Int, $afterCursor: String, $filePath: String) {
|
||||
node(id: $repo) {
|
||||
@ -137,6 +136,7 @@ export const RepositoryCommitsPage: FC<RepositoryCommitsPageProps> = props => {
|
||||
|
||||
let sourceType = RepositoryType.GIT_REPOSITORY
|
||||
|
||||
const connectionState = useUrlSearchParamsForConnectionState([])
|
||||
const { connection, error, loading, hasNextPage, fetchMore } = useShowMorePagination<
|
||||
RepositoryGitCommitsResult,
|
||||
RepositoryGitCommitsVariables,
|
||||
@ -147,8 +147,6 @@ export const RepositoryCommitsPage: FC<RepositoryCommitsPageProps> = props => {
|
||||
repo: repo.id,
|
||||
revspec: props.revision,
|
||||
filePath: filePath ?? null,
|
||||
first: REPOSITORY_GIT_COMMITS_PER_PAGE,
|
||||
afterCursor: null,
|
||||
},
|
||||
getConnection: result => {
|
||||
const { node } = dataOrThrowErrors(result)
|
||||
@ -171,6 +169,7 @@ export const RepositoryCommitsPage: FC<RepositoryCommitsPageProps> = props => {
|
||||
useAlternateAfterCursor: true,
|
||||
errorPolicy: 'all',
|
||||
},
|
||||
state: connectionState,
|
||||
})
|
||||
|
||||
const getPageTitle = (): string => {
|
||||
@ -267,7 +266,6 @@ export const RepositoryCommitsPage: FC<RepositoryCommitsPageProps> = props => {
|
||||
<SummaryContainer centered={true}>
|
||||
<ConnectionSummary
|
||||
centered={true}
|
||||
first={REPOSITORY_GIT_COMMITS_PER_PAGE}
|
||||
connection={connection}
|
||||
noun={getRefType(sourceType)}
|
||||
pluralNoun={pluralize(getRefType(sourceType), 0)}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import type { NavigateFunction, Location } from 'react-router-dom'
|
||||
import { type Observable, Subject, Subscription } from 'rxjs'
|
||||
import type { Location, NavigateFunction } from 'react-router-dom'
|
||||
import { Subject, Subscription, type Observable } from 'rxjs'
|
||||
import { distinctUntilChanged, map, startWith } from 'rxjs/operators'
|
||||
|
||||
import { createAggregateError } from '@sourcegraph/common'
|
||||
import { gql } from '@sourcegraph/http-client'
|
||||
import { CardHeader, Card } from '@sourcegraph/wildcard'
|
||||
import { Card, CardHeader } from '@sourcegraph/wildcard'
|
||||
|
||||
import { queryGraphQL } from '../../backend/graphql'
|
||||
import { FilteredConnection } from '../../components/FilteredConnection'
|
||||
@ -22,7 +22,7 @@ function queryRepositoryComparisonCommits(args: {
|
||||
repo: Scalars['ID']
|
||||
base: string | null
|
||||
head: string | null
|
||||
first?: number
|
||||
first?: number | null
|
||||
path?: string
|
||||
}): Observable<RepositoryComparisonRepository['comparison']['commits']> {
|
||||
return queryGraphQL<RepositoryComparisonCommitsResult>(
|
||||
@ -130,7 +130,7 @@ export class RepositoryCompareCommitsPage extends React.PureComponent<Props> {
|
||||
}
|
||||
|
||||
private queryCommits = (args: {
|
||||
first?: number
|
||||
first?: number | null
|
||||
}): Observable<RepositoryComparisonRepository['comparison']['commits']> =>
|
||||
queryRepositoryComparisonCommits({
|
||||
...args,
|
||||
|
||||
@ -6,14 +6,14 @@ import { map } from 'rxjs/operators'
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
import type { Scalars } from '@sourcegraph/shared/src/graphql-operations'
|
||||
|
||||
import { fileDiffFields, diffStatFields } from '../../backend/diff'
|
||||
import { diffStatFields, fileDiffFields } from '../../backend/diff'
|
||||
import { requestGraphQL } from '../../backend/graphql'
|
||||
import { FileDiffNode, type FileDiffNodeProps } from '../../components/diff/FileDiffNode'
|
||||
import { type ConnectionQueryArguments, FilteredConnection } from '../../components/FilteredConnection'
|
||||
import { FilteredConnection, type FilteredConnectionQueryArguments } from '../../components/FilteredConnection'
|
||||
import type {
|
||||
FileDiffFields,
|
||||
RepositoryComparisonDiffResult,
|
||||
RepositoryComparisonDiffVariables,
|
||||
FileDiffFields,
|
||||
} from '../../graphql-operations'
|
||||
|
||||
import type { RepositoryCompareAreaPageProps } from './RepositoryCompareArea'
|
||||
@ -95,7 +95,7 @@ interface RepositoryCompareDiffPageProps extends RepositoryCompareAreaPageProps
|
||||
/** A page with the file diffs in the comparison. */
|
||||
export const RepositoryCompareDiffPage: React.FunctionComponent<RepositoryCompareDiffPageProps> = props => {
|
||||
const queryDiffs = useCallback(
|
||||
(args: ConnectionQueryArguments): Observable<RepositoryComparisonDiff['comparison']['fileDiffs']> =>
|
||||
(args: FilteredConnectionQueryArguments): Observable<RepositoryComparisonDiff['comparison']['fileDiffs']> =>
|
||||
queryRepositoryComparisonFileDiffs({
|
||||
first: args.first ?? null,
|
||||
after: args.after ?? null,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
|
||||
import { type Observable, of } from 'rxjs'
|
||||
import { of, type Observable } from 'rxjs'
|
||||
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
|
||||
import { LoadingSpinner } from '@sourcegraph/wildcard'
|
||||
|
||||
@ -10,15 +10,15 @@ import { FilteredConnection, type FilteredConnectionQueryArguments } from '../..
|
||||
import { PageTitle } from '../../components/PageTitle'
|
||||
import {
|
||||
GitRefType,
|
||||
type Scalars,
|
||||
type GitRefConnectionFields,
|
||||
type GitRefFields,
|
||||
type RepositoryFields,
|
||||
type Scalars,
|
||||
} from '../../graphql-operations'
|
||||
import {
|
||||
GitReferenceNode,
|
||||
type GitReferenceNodeProps,
|
||||
queryGitReferences as queryGitReferencesFromBackend,
|
||||
type GitReferenceNodeProps,
|
||||
} from '../GitReference'
|
||||
|
||||
interface Props extends TelemetryV2Props {
|
||||
@ -26,7 +26,7 @@ interface Props extends TelemetryV2Props {
|
||||
isPackage?: boolean
|
||||
queryGitReferences?: (args: {
|
||||
repo: Scalars['ID']
|
||||
first?: number
|
||||
first?: number | null
|
||||
query?: string
|
||||
type: GitRefType
|
||||
withBehindAhead?: boolean
|
||||
@ -37,7 +37,7 @@ interface Props extends TelemetryV2Props {
|
||||
export const RepositoryReleasesTagsPage: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
|
||||
repo,
|
||||
isPackage,
|
||||
queryGitReferences: queryGitReferences = queryGitReferencesFromBackend,
|
||||
queryGitReferences = queryGitReferencesFromBackend,
|
||||
telemetryRecorder,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
import { mdiCog, mdiFileOutline, mdiSourceCommit, mdiGlasses, mdiInformationOutline } from '@mdi/js'
|
||||
import { mdiCog, mdiFileOutline, mdiGlasses, mdiInformationOutline, mdiSourceCommit } from '@mdi/js'
|
||||
import classNames from 'classnames'
|
||||
import { escapeRegExp } from 'lodash'
|
||||
|
||||
@ -340,7 +340,6 @@ const Contributors: React.FC<ContributorsProps> = ({ repo, filePath }) => {
|
||||
<ConnectionSummary
|
||||
compact={true}
|
||||
connection={connection}
|
||||
first={COUNT}
|
||||
noun="contributor"
|
||||
pluralNoun="contributors"
|
||||
hasNextPage={connection.pageInfo.hasNextPage}
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react'
|
||||
|
||||
import classNames from 'classnames'
|
||||
|
||||
import { FilterLink, type RevisionsProps, TabIndex } from '@sourcegraph/branded'
|
||||
import { FilterLink, TabIndex, type RevisionsProps } from '@sourcegraph/branded'
|
||||
import { styles } from '@sourcegraph/branded/src/search-ui/results/sidebar/SearchFilterSection'
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
import { FilterType } from '@sourcegraph/shared/src/search/query/filters'
|
||||
@ -11,10 +11,10 @@ import { Button, LoadingSpinner, Tab, TabList, TabPanel, TabPanels, Tabs, Text }
|
||||
|
||||
import { useShowMorePagination } from '../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
GitRefType,
|
||||
type SearchSidebarGitRefFields,
|
||||
type SearchSidebarGitRefsResult,
|
||||
type SearchSidebarGitRefsVariables,
|
||||
type SearchSidebarGitRefFields,
|
||||
GitRefType,
|
||||
} from '../../../graphql-operations'
|
||||
|
||||
import revisionStyles from './Revisions.module.scss'
|
||||
@ -70,7 +70,6 @@ const RevisionList: React.FunctionComponent<React.PropsWithChildren<RevisionList
|
||||
>({
|
||||
query: GIT_REVS_QUERY,
|
||||
variables: {
|
||||
first: DEFAULT_FIRST,
|
||||
repo: repoName,
|
||||
query,
|
||||
type,
|
||||
@ -82,6 +81,9 @@ const RevisionList: React.FunctionComponent<React.PropsWithChildren<RevisionList
|
||||
}
|
||||
return data?.repository?.gitRefs
|
||||
},
|
||||
options: {
|
||||
pageSize: DEFAULT_FIRST,
|
||||
},
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
@ -184,6 +186,5 @@ Revisions.displayName = 'Revisions'
|
||||
|
||||
export const getRevisions = (props: Omit<RevisionsProps, 'query'>) =>
|
||||
function RevisionsSection(query: string) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return <Revisions {...props} query={query} />
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useCallback, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { mdiAccount } from '@mdi/js'
|
||||
import classNames from 'classnames'
|
||||
@ -9,44 +9,43 @@ import { pluralize } from '@sourcegraph/common'
|
||||
import { useLazyQuery, useMutation, useQuery } from '@sourcegraph/http-client'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
|
||||
import { Card, Text, Alert, PageSwitcher, Link, Select, Button, Badge, Tooltip } from '@sourcegraph/wildcard'
|
||||
import { Alert, Badge, Button, Card, Link, PageSwitcher, Select, Text, Tooltip } from '@sourcegraph/wildcard'
|
||||
|
||||
import { usePageSwitcherPagination } from '../../components/FilteredConnection/hooks/usePageSwitcherPagination'
|
||||
import {
|
||||
type RejectAccessRequestResult,
|
||||
type RejectAccessRequestVariables,
|
||||
AccessRequestStatus,
|
||||
type AccessRequestCreateUserResult,
|
||||
type AccessRequestCreateUserVariables,
|
||||
type AccessRequestNode,
|
||||
type ApproveAccessRequestResult,
|
||||
type ApproveAccessRequestVariables,
|
||||
type DoesUsernameExistResult,
|
||||
type DoesUsernameExistVariables,
|
||||
type AccessRequestCreateUserResult,
|
||||
type AccessRequestCreateUserVariables,
|
||||
type GetAccessRequestsResult,
|
||||
type GetAccessRequestsVariables,
|
||||
type HasLicenseSeatsResult,
|
||||
type HasLicenseSeatsVariables,
|
||||
AccessRequestStatus,
|
||||
type AccessRequestNode,
|
||||
type GetAccessRequestsVariables,
|
||||
type GetAccessRequestsResult,
|
||||
type RejectAccessRequestResult,
|
||||
type RejectAccessRequestVariables,
|
||||
} from '../../graphql-operations'
|
||||
import { useURLSyncedString } from '../../hooks/useUrlSyncedString'
|
||||
import { AccountCreatedAlert } from '../components/AccountCreatedAlert'
|
||||
import { SiteAdminPageTitle } from '../components/SiteAdminPageTitle'
|
||||
import { type IColumn, Table } from '../UserManagement/components/Table'
|
||||
import { Table, type IColumn } from '../UserManagement/components/Table'
|
||||
|
||||
import {
|
||||
APPROVE_ACCESS_REQUEST,
|
||||
ACCESS_REQUEST_CREATE_USER,
|
||||
APPROVE_ACCESS_REQUEST,
|
||||
DOES_USERNAME_EXIST,
|
||||
GET_ACCESS_REQUESTS_LIST,
|
||||
REJECT_ACCESS_REQUEST,
|
||||
HAS_LICENSE_SEATS,
|
||||
REJECT_ACCESS_REQUEST,
|
||||
} from './queries'
|
||||
|
||||
import styles from './index.module.scss'
|
||||
|
||||
/**
|
||||
* Converts a name to a username by removing all non-alphanumeric characters and converting to lowercase.
|
||||
*
|
||||
* @param name user's name / full name
|
||||
* @param randomize whether to add a random suffix to the username to avoid collisions
|
||||
* @returns username
|
||||
@ -210,7 +209,6 @@ export const AccessRequestsPage: React.FunctionComponent<Props> = ({ telemetryRe
|
||||
options: {
|
||||
fetchPolicy: 'cache-first',
|
||||
pageSize: FIRST_COUNT,
|
||||
useURL: true,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { mdiBlockHelper, mdiCog, mdiDotsHorizontal } from '@mdi/js'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { dataOrThrowErrors, useQuery } from '@sourcegraph/http-client'
|
||||
import { RepoLink } from '@sourcegraph/shared/src/components/RepoLink'
|
||||
@ -30,16 +28,10 @@ import {
|
||||
} from '@sourcegraph/wildcard'
|
||||
|
||||
import { externalRepoIcon } from '../components/externalServices/externalServices'
|
||||
import {
|
||||
buildFilterArgs,
|
||||
FilterControl,
|
||||
type Filter,
|
||||
type FilterOption,
|
||||
type FilterValues,
|
||||
} from '../components/FilteredConnection'
|
||||
import { buildFilterArgs, FilterControl, type Filter, type FilterOption } from '../components/FilteredConnection'
|
||||
import { useUrlSearchParamsForConnectionState } from '../components/FilteredConnection/hooks/connectionState'
|
||||
import { useShowMorePagination } from '../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import { ConnectionSummary } from '../components/FilteredConnection/ui'
|
||||
import { getFilterFromURL, getUrlQuery } from '../components/FilteredConnection/utils'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import type {
|
||||
ExternalServiceKindsResult,
|
||||
@ -151,8 +143,6 @@ const PackageNode: React.FunctionComponent<React.PropsWithChildren<PackageNodePr
|
||||
|
||||
interface SiteAdminPackagesPageProps extends TelemetryProps, TelemetryV2Props {}
|
||||
|
||||
const DEFAULT_FIRST = 15
|
||||
|
||||
interface PackagesModalState {
|
||||
type: 'add' | 'manage' | null
|
||||
node?: SiteAdminPackageFields
|
||||
@ -165,8 +155,6 @@ export const SiteAdminPackagesPage: React.FunctionComponent<React.PropsWithChild
|
||||
telemetryService,
|
||||
telemetryRecorder,
|
||||
}) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [modalState, setModalState] = useState<PackagesModalState>({ type: null })
|
||||
|
||||
useEffect(() => {
|
||||
@ -197,7 +185,7 @@ export const SiteAdminPackagesPage: React.FunctionComponent<React.PropsWithChild
|
||||
return values
|
||||
}, [extSvcs?.externalServices.nodes])
|
||||
|
||||
const filters = useMemo<Filter[]>(
|
||||
const filters = useMemo<Filter<'ecosystem'>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'ecosystem',
|
||||
@ -216,73 +204,28 @@ export const SiteAdminPackagesPage: React.FunctionComponent<React.PropsWithChild
|
||||
[ecosystemFilterValues]
|
||||
)
|
||||
|
||||
const [filterValues, setFilterValues] = useState<FilterValues>(() =>
|
||||
getFilterFromURL(new URLSearchParams(location.search), filters)
|
||||
)
|
||||
|
||||
const [searchValue, setSearchValue] = useState<string>(
|
||||
() => new URLSearchParams(location.search).get('query') || ''
|
||||
)
|
||||
|
||||
const query = useDebounce(searchValue, 200)
|
||||
|
||||
useEffect(() => {
|
||||
const searchFragment = getUrlQuery({
|
||||
query: searchValue,
|
||||
filters,
|
||||
filterValues,
|
||||
search: location.search,
|
||||
})
|
||||
const searchFragmentParams = new URLSearchParams(searchFragment)
|
||||
searchFragmentParams.sort()
|
||||
|
||||
const oldParams = new URLSearchParams(location.search)
|
||||
oldParams.sort()
|
||||
|
||||
if (!isEqual(Array.from(searchFragmentParams), Array.from(oldParams))) {
|
||||
navigate(
|
||||
{
|
||||
search: searchFragment,
|
||||
hash: location.hash,
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
// Do not throw away flash messages
|
||||
state: location.state,
|
||||
}
|
||||
)
|
||||
}
|
||||
}, [filterValues, filters, searchValue, location, navigate])
|
||||
|
||||
const variables = useMemo<PackagesVariables>(() => {
|
||||
const args = buildFilterArgs(filters, filterValues)
|
||||
|
||||
return {
|
||||
name: query,
|
||||
kind: null,
|
||||
after: null,
|
||||
first: DEFAULT_FIRST,
|
||||
...args,
|
||||
}
|
||||
}, [filters, filterValues, query])
|
||||
|
||||
const [connectionState, setConnectionState] = useUrlSearchParamsForConnectionState(filters)
|
||||
const debouncedQuery = useDebounce(connectionState.query, 300)
|
||||
const {
|
||||
connection,
|
||||
error: packagesError,
|
||||
loading: packagesLoading,
|
||||
fetchMore,
|
||||
hasNextPage,
|
||||
} = useShowMorePagination<PackagesResult, PackagesVariables, SiteAdminPackageFields>({
|
||||
} = useShowMorePagination<PackagesResult, PackagesVariables, SiteAdminPackageFields, typeof connectionState>({
|
||||
query: PACKAGES_QUERY,
|
||||
variables,
|
||||
variables: {
|
||||
...buildFilterArgs(filters, connectionState),
|
||||
query: debouncedQuery,
|
||||
},
|
||||
getConnection: result => {
|
||||
const data = dataOrThrowErrors(result)
|
||||
return data.packageRepoReferences
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'cache-and-network',
|
||||
useURL: true,
|
||||
},
|
||||
state: [connectionState, setConnectionState],
|
||||
})
|
||||
|
||||
const error = extSvcError || packagesError
|
||||
@ -331,8 +274,8 @@ export const SiteAdminPackagesPage: React.FunctionComponent<React.PropsWithChild
|
||||
className="flex-1"
|
||||
placeholder="Search packages..."
|
||||
name="query"
|
||||
value={searchValue}
|
||||
onChange={event => setSearchValue(event.currentTarget.value)}
|
||||
value={connectionState.query}
|
||||
onChange={event => setConnectionState(prev => ({ ...prev, query: event.currentTarget.value }))}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
@ -343,17 +286,16 @@ export const SiteAdminPackagesPage: React.FunctionComponent<React.PropsWithChild
|
||||
<div className="d-flex align-items-end justify-content-between mt-3">
|
||||
<FilterControl
|
||||
filters={filters}
|
||||
values={filterValues}
|
||||
onValueSelect={(filter: Filter, value: FilterOption['value'] | null) =>
|
||||
setFilterValues(values => ({ ...values, [filter.id]: value }))
|
||||
values={connectionState}
|
||||
onValueSelect={(filter, value) =>
|
||||
setConnectionState(prev => ({ ...prev, [filter.id]: value }))
|
||||
}
|
||||
/>
|
||||
{connection && (
|
||||
<ConnectionSummary
|
||||
connection={connection}
|
||||
connectionQuery={query}
|
||||
connectionQuery={connectionState.query}
|
||||
hasNextPage={hasNextPage}
|
||||
first={DEFAULT_FIRST}
|
||||
noun="package"
|
||||
pluralNoun="packages"
|
||||
className="mb-0"
|
||||
|
||||
@ -1,21 +1,14 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
|
||||
import { isEqual } from 'lodash'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { useQuery } from '@sourcegraph/http-client'
|
||||
import { Container, ErrorAlert, Input, LoadingSpinner, PageSwitcher, useDebounce } from '@sourcegraph/wildcard'
|
||||
|
||||
import { EXTERNAL_SERVICE_IDS_AND_NAMES } from '../components/externalServices/backend'
|
||||
import {
|
||||
buildFilterArgs,
|
||||
FilterControl,
|
||||
type Filter,
|
||||
type FilterOption,
|
||||
type FilterValues,
|
||||
} from '../components/FilteredConnection'
|
||||
import { buildFilterArgs, FilterControl, type Filter, type FilterOption } from '../components/FilteredConnection'
|
||||
import { useUrlSearchParamsForConnectionState } from '../components/FilteredConnection/hooks/connectionState'
|
||||
import { usePageSwitcherPagination } from '../components/FilteredConnection/hooks/usePageSwitcherPagination'
|
||||
import { getFilterFromURL, getUrlQuery } from '../components/FilteredConnection/utils'
|
||||
import {
|
||||
RepositoryOrderBy,
|
||||
type ExternalServiceIDsAndNamesResult,
|
||||
@ -84,9 +77,9 @@ const STATUS_FILTERS: { [label: string]: FilterOption } = {
|
||||
},
|
||||
}
|
||||
|
||||
const FILTERS: Filter[] = [
|
||||
const FILTERS: Filter<'orderBy' | 'status' | 'codeHost'>[] = [
|
||||
{
|
||||
id: 'order',
|
||||
id: 'orderBy',
|
||||
label: 'Order',
|
||||
type: 'select',
|
||||
options: [
|
||||
@ -147,7 +140,6 @@ export const SiteAdminRepositoriesContainer: React.FunctionComponent<{ alwaysPol
|
||||
stopPolling,
|
||||
} = useQuery<StatusAndRepoStatsResult>(STATUS_AND_REPO_STATS, {})
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (alwaysPoll || data?.repositoryStats?.total === 0 || data?.repositoryStats?.cloning !== 0) {
|
||||
@ -166,114 +158,60 @@ export const SiteAdminRepositoriesContainer: React.FunctionComponent<{ alwaysPol
|
||||
{}
|
||||
)
|
||||
|
||||
const filters = useMemo(() => {
|
||||
if (!extSvcs) {
|
||||
return FILTERS
|
||||
}
|
||||
|
||||
const filtersWithExternalServices = FILTERS.slice() // use slice to copy array
|
||||
if (location.pathname !== PageRoutes.SetupWizard) {
|
||||
const values = [
|
||||
{
|
||||
label: 'All',
|
||||
value: 'all',
|
||||
tooltip: 'Show all repositories',
|
||||
args: {},
|
||||
},
|
||||
]
|
||||
|
||||
for (const extSvc of extSvcs.externalServices.nodes) {
|
||||
values.push({
|
||||
label: extSvc.displayName,
|
||||
value: extSvc.id,
|
||||
tooltip: `Show all repositories discovered on ${extSvc.displayName}`,
|
||||
args: { externalService: extSvc.id },
|
||||
})
|
||||
}
|
||||
filtersWithExternalServices.push({
|
||||
id: 'codeHost',
|
||||
label: 'Code Host',
|
||||
type: 'select',
|
||||
options: values,
|
||||
})
|
||||
}
|
||||
return filtersWithExternalServices
|
||||
}, [extSvcs, location.pathname])
|
||||
|
||||
const [filterValues, setFilterValues] = useState<FilterValues>(() =>
|
||||
getFilterFromURL(new URLSearchParams(location.search), filters)
|
||||
const filters = useMemo<Filter<'orderBy' | 'status' | 'codeHost'>[]>(
|
||||
() => [
|
||||
...FILTERS,
|
||||
...(extSvcs && location.pathname !== PageRoutes.SetupWizard
|
||||
? [
|
||||
{
|
||||
id: 'codeHost' as const,
|
||||
label: 'Code Host',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{
|
||||
label: 'All',
|
||||
value: 'all',
|
||||
tooltip: 'Show all repositories',
|
||||
args: {},
|
||||
},
|
||||
...extSvcs.externalServices.nodes.map(extSvc => ({
|
||||
label: extSvc.displayName,
|
||||
value: extSvc.id,
|
||||
tooltip: `Show all repositories discovered on ${extSvc.displayName}`,
|
||||
args: { externalService: extSvc.id },
|
||||
})),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[extSvcs, location.pathname]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setFilterValues(getFilterFromURL(new URLSearchParams(location.search), filters))
|
||||
}, [filters, location])
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string>(
|
||||
() => new URLSearchParams(location.search).get('query') || ''
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const searchFragment = getUrlQuery({
|
||||
query: searchQuery,
|
||||
filters,
|
||||
filterValues,
|
||||
search: location.search,
|
||||
})
|
||||
const searchFragmentParams = new URLSearchParams(searchFragment)
|
||||
searchFragmentParams.sort()
|
||||
|
||||
const oldParams = new URLSearchParams(location.search)
|
||||
oldParams.sort()
|
||||
|
||||
if (!isEqual(Array.from(searchFragmentParams), Array.from(oldParams))) {
|
||||
navigate(
|
||||
{
|
||||
search: searchFragment,
|
||||
hash: location.hash,
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
// Do not throw away flash messages
|
||||
state: location.state,
|
||||
}
|
||||
)
|
||||
}
|
||||
}, [filters, filterValues, searchQuery, location, navigate])
|
||||
|
||||
const variables = useMemo<RepositoriesVariables>(() => {
|
||||
const args = buildFilterArgs(filters, filterValues)
|
||||
|
||||
return {
|
||||
...args,
|
||||
query: searchQuery,
|
||||
indexed: args.indexed ?? true,
|
||||
notIndexed: args.notIndexed ?? true,
|
||||
failedFetch: args.failedFetch ?? false,
|
||||
corrupted: args.corrupted ?? false,
|
||||
cloneStatus: args.cloneStatus ?? null,
|
||||
externalService: args.externalService ?? null,
|
||||
} as RepositoriesVariables
|
||||
}, [filters, searchQuery, filterValues])
|
||||
|
||||
const debouncedVariables = useDebounce(variables, 300)
|
||||
|
||||
const [connectionState, setConnectionState] = useUrlSearchParamsForConnectionState(filters)
|
||||
const debouncedQuery = useDebounce(connectionState.query, 300)
|
||||
const {
|
||||
connection,
|
||||
loading: reposLoading,
|
||||
error: reposError,
|
||||
refetch,
|
||||
...paginationProps
|
||||
} = usePageSwitcherPagination<RepositoriesResult, RepositoriesVariables, SiteAdminRepositoryFields>({
|
||||
} = usePageSwitcherPagination<
|
||||
RepositoriesResult,
|
||||
RepositoriesVariables,
|
||||
SiteAdminRepositoryFields,
|
||||
typeof connectionState
|
||||
>({
|
||||
query: REPOSITORIES_QUERY,
|
||||
variables: debouncedVariables,
|
||||
variables: {
|
||||
...buildFilterArgs(filters, connectionState),
|
||||
query: debouncedQuery,
|
||||
},
|
||||
getConnection: ({ data }) => data?.repositories || undefined,
|
||||
options: { pollInterval: 5000 },
|
||||
state: [connectionState, setConnectionState],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
refetch(debouncedVariables)
|
||||
}, [refetch, debouncedVariables])
|
||||
|
||||
const error = repoStatsError || extSvcError || reposError
|
||||
const loading = repoStatsLoading || extSvcLoading || reposLoading
|
||||
const debouncedLoading = useDebounce(loading, 300)
|
||||
@ -296,7 +234,7 @@ export const SiteAdminRepositoriesContainer: React.FunctionComponent<{ alwaysPol
|
||||
color: 'var(--body-color)',
|
||||
position: 'right',
|
||||
tooltip: 'The number of repositories that are queued to be cloned.',
|
||||
onClick: () => setFilterValues(values => ({ ...values, status: STATUS_FILTERS.NotCloned.value })),
|
||||
onClick: () => setConnectionState(prev => ({ ...prev, status: STATUS_FILTERS.NotCloned.value })),
|
||||
},
|
||||
{
|
||||
value: data.repositoryStats.cloning,
|
||||
@ -304,7 +242,7 @@ export const SiteAdminRepositoriesContainer: React.FunctionComponent<{ alwaysPol
|
||||
color: data.repositoryStats.cloning > 0 ? 'var(--primary)' : 'var(--body-color)',
|
||||
position: 'right',
|
||||
tooltip: 'The number of repositories that are currently being cloned.',
|
||||
onClick: () => setFilterValues(values => ({ ...values, status: STATUS_FILTERS.Cloning.value })),
|
||||
onClick: () => setConnectionState(prev => ({ ...prev, status: STATUS_FILTERS.Cloning.value })),
|
||||
},
|
||||
{
|
||||
value: data.repositoryStats.cloned,
|
||||
@ -312,7 +250,7 @@ export const SiteAdminRepositoriesContainer: React.FunctionComponent<{ alwaysPol
|
||||
color: 'var(--success)',
|
||||
position: 'right',
|
||||
tooltip: 'The number of repositories that have been cloned.',
|
||||
onClick: () => setFilterValues(values => ({ ...values, status: STATUS_FILTERS.Cloned.value })),
|
||||
onClick: () => setConnectionState(prev => ({ ...prev, status: STATUS_FILTERS.Cloned.value })),
|
||||
},
|
||||
{
|
||||
value: data.repositoryStats.indexed,
|
||||
@ -320,7 +258,7 @@ export const SiteAdminRepositoriesContainer: React.FunctionComponent<{ alwaysPol
|
||||
color: 'var(--body-color)',
|
||||
position: 'right',
|
||||
tooltip: 'The number of repositories that have been indexed for search.',
|
||||
onClick: () => setFilterValues(values => ({ ...values, status: STATUS_FILTERS.Indexed.value })),
|
||||
onClick: () => setConnectionState(prev => ({ ...prev, status: STATUS_FILTERS.Indexed.value })),
|
||||
},
|
||||
{
|
||||
value: data.repositoryStats.failedFetch,
|
||||
@ -329,7 +267,10 @@ export const SiteAdminRepositoriesContainer: React.FunctionComponent<{ alwaysPol
|
||||
position: 'right',
|
||||
tooltip: 'The number of repositories where the last syncing attempt produced an error.',
|
||||
onClick: () =>
|
||||
setFilterValues(values => ({ ...values, status: STATUS_FILTERS.FailedFetchOrClone.value })),
|
||||
setConnectionState(prev => ({
|
||||
...prev,
|
||||
status: STATUS_FILTERS.FailedFetchOrClone.value,
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
@ -341,11 +282,11 @@ export const SiteAdminRepositoriesContainer: React.FunctionComponent<{ alwaysPol
|
||||
position: 'right',
|
||||
tooltip:
|
||||
'The number of repositories where corruption has been detected. Reclone these repositories to get rid of corruption.',
|
||||
onClick: () => setFilterValues(values => ({ ...values, status: STATUS_FILTERS.Corrupted.value })),
|
||||
onClick: () => setConnectionState(prev => ({ ...prev, status: STATUS_FILTERS.Corrupted.value })),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}, [data, setFilterValues])
|
||||
}, [setConnectionState, data])
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -358,9 +299,9 @@ export const SiteAdminRepositoriesContainer: React.FunctionComponent<{ alwaysPol
|
||||
<div className="d-flex flex-sm-row flex-column-reverse justify-content-center">
|
||||
<FilterControl
|
||||
filters={filters}
|
||||
values={filterValues}
|
||||
onValueSelect={(filter: Filter, value: FilterOption['value'] | null) =>
|
||||
setFilterValues(values => ({ ...values, [filter.id]: value }))
|
||||
values={connectionState}
|
||||
onValueSelect={(filter, value) =>
|
||||
setConnectionState(prev => ({ ...prev, [filter.id]: value }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
@ -368,8 +309,13 @@ export const SiteAdminRepositoriesContainer: React.FunctionComponent<{ alwaysPol
|
||||
className="flex-1 md-ml-5 mb-1"
|
||||
placeholder="Search repositories..."
|
||||
name="query"
|
||||
value={searchQuery}
|
||||
onChange={event => setSearchQuery(event.currentTarget.value)}
|
||||
value={connectionState.query}
|
||||
onChange={event =>
|
||||
setConnectionState(prev => ({
|
||||
...prev,
|
||||
query: event.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
|
||||
@ -1,23 +1,23 @@
|
||||
import { type FC, useEffect, useState, useCallback } from 'react'
|
||||
import { useCallback, useEffect, useState, type FC } from 'react'
|
||||
|
||||
import { mdiWebhook, mdiDelete, mdiPencil } from '@mdi/js'
|
||||
import { mdiDelete, mdiPencil, mdiWebhook } from '@mdi/js'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { type TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
ButtonLink,
|
||||
Code,
|
||||
Container,
|
||||
ErrorAlert,
|
||||
H2,
|
||||
H5,
|
||||
Icon,
|
||||
Link,
|
||||
PageHeader,
|
||||
ErrorAlert,
|
||||
Icon,
|
||||
Alert,
|
||||
Text,
|
||||
Code,
|
||||
} from '@sourcegraph/wildcard'
|
||||
|
||||
import { CreatedByAndUpdatedByInfoByline } from '../components/Byline/CreatedByAndUpdatedByInfoByline'
|
||||
@ -56,7 +56,7 @@ export const SiteAdminWebhookPage: FC<WebhookPageProps> = props => {
|
||||
fetchMore,
|
||||
connection,
|
||||
error: webhookLogsError,
|
||||
} = useWebhookLogsConnection(id, 20, onlyErrors)
|
||||
} = useWebhookLogsConnection(id, onlyErrors)
|
||||
const { loading: webhookLoading, data: webhookData, error: webhookError } = useWebhookQuery(id)
|
||||
|
||||
useEffect(() => {
|
||||
@ -138,7 +138,6 @@ export const SiteAdminWebhookPage: FC<WebhookPageProps> = props => {
|
||||
<SummaryContainer className="mt-2">
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={false}
|
||||
first={connection.totalCount ?? 0}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="webhook log"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import { mdiWebhook, mdiMapSearch, mdiPlus } from '@mdi/js'
|
||||
import { mdiMapSearch, mdiPlus, mdiWebhook } from '@mdi/js'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
@ -18,7 +18,7 @@ import {
|
||||
} from '../components/FilteredConnection/ui'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
|
||||
import { useWebhooksConnection, useWebhookPageHeader } from './backend'
|
||||
import { useWebhookPageHeader, useWebhooksConnection } from './backend'
|
||||
import { WebhookNode } from './WebhookNode'
|
||||
import { PerformanceGauge } from './webhooks/PerformanceGauge'
|
||||
|
||||
@ -89,7 +89,6 @@ export const SiteAdminWebhooksPage: React.FunctionComponent<React.PropsWithChild
|
||||
<SummaryContainer className="mt-2" centered={true}>
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={false}
|
||||
first={connection.totalCount ?? 0}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="webhook"
|
||||
|
||||
@ -23,6 +23,8 @@ import type {
|
||||
CreateUserResult,
|
||||
DeleteOrganizationResult,
|
||||
DeleteOrganizationVariables,
|
||||
DeleteWebhookResult,
|
||||
DeleteWebhookVariables,
|
||||
ExternalServiceKind,
|
||||
FeatureFlagFields,
|
||||
FeatureFlagsResult,
|
||||
@ -63,8 +65,6 @@ import type {
|
||||
WebhookPageHeaderVariables,
|
||||
WebhooksListResult,
|
||||
WebhooksListVariables,
|
||||
DeleteWebhookResult,
|
||||
DeleteWebhookVariables,
|
||||
} from '../graphql-operations'
|
||||
import { accessTokenFragment } from '../settings/tokens/AccessTokenNode'
|
||||
|
||||
@ -74,7 +74,7 @@ import { WEBHOOK_LOGS_BY_ID } from './webhooks/backend'
|
||||
* Fetches all organizations.
|
||||
*/
|
||||
export function fetchAllOrganizations(args: {
|
||||
first?: number
|
||||
first?: number | null
|
||||
query?: string
|
||||
}): Observable<OrganizationsConnectionFields> {
|
||||
return requestGraphQL<OrganizationsResult, OrganizationsVariables>(
|
||||
@ -165,15 +165,15 @@ export const REPOSITORIES_QUERY = gql`
|
||||
$last: Int
|
||||
$after: String
|
||||
$before: String
|
||||
$query: String
|
||||
$indexed: Boolean
|
||||
$notIndexed: Boolean
|
||||
$failedFetch: Boolean
|
||||
$corrupted: Boolean
|
||||
$cloneStatus: CloneStatus
|
||||
$orderBy: RepositoryOrderBy
|
||||
$descending: Boolean
|
||||
$externalService: ID
|
||||
$query: String = ""
|
||||
$indexed: Boolean = true
|
||||
$notIndexed: Boolean = true
|
||||
$failedFetch: Boolean = false
|
||||
$corrupted: Boolean = false
|
||||
$cloneStatus: CloneStatus = null
|
||||
$orderBy: RepositoryOrderBy = REPOSITORY_NAME
|
||||
$descending: Boolean = false
|
||||
$externalService: ID = null
|
||||
) {
|
||||
repositories(
|
||||
first: $first
|
||||
@ -799,7 +799,7 @@ export const STATUS_AND_REPO_STATS = gql`
|
||||
}
|
||||
`
|
||||
|
||||
export function queryAccessTokens(args: { first?: number }): Observable<SiteAdminAccessTokenConnectionFields> {
|
||||
export function queryAccessTokens(args: { first?: number | null }): Observable<SiteAdminAccessTokenConnectionFields> {
|
||||
return requestGraphQL<SiteAdminAccessTokensResult, SiteAdminAccessTokensVariables>(
|
||||
gql`
|
||||
query SiteAdminAccessTokens($first: Int) {
|
||||
@ -930,14 +930,11 @@ export const useWebhookQuery = (id: string): QueryResult<WebhookByIdResult, Webh
|
||||
|
||||
export const useWebhookLogsConnection = (
|
||||
webhookID: string,
|
||||
first: number,
|
||||
onlyErrors: boolean
|
||||
): UseShowMorePaginationResult<WebhookLogsByWebhookIDResult, WebhookLogFields> =>
|
||||
useShowMorePagination<WebhookLogsByWebhookIDResult, WebhookLogsByWebhookIDVariables, WebhookLogFields>({
|
||||
query: WEBHOOK_LOGS_BY_ID,
|
||||
variables: {
|
||||
first: first ?? 20,
|
||||
after: null,
|
||||
onlyErrors,
|
||||
onlyUnmatched: false,
|
||||
webhookID,
|
||||
@ -1001,8 +998,8 @@ const siteAdminPackageFieldsFragment = gql`
|
||||
`
|
||||
|
||||
export const PACKAGES_QUERY = gql`
|
||||
query Packages($kind: PackageRepoReferenceKind, $name: String, $first: Int!, $after: String) {
|
||||
packageRepoReferences(kind: $kind, name: $name, first: $first, after: $after) {
|
||||
query Packages($kind: PackageRepoReferenceKind = null, $query: String = "", $first: Int, $after: String) {
|
||||
packageRepoReferences(kind: $kind, name: $query, first: $first, after: $after) {
|
||||
nodes {
|
||||
...SiteAdminPackageFields
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { type FC, useEffect } from 'react'
|
||||
import { useEffect, type FC } from 'react'
|
||||
|
||||
import { mdiAlertCircle, mdiWebhook, mdiMapSearch, mdiPencil, mdiPlus } from '@mdi/js'
|
||||
import { mdiAlertCircle, mdiMapSearch, mdiPencil, mdiPlus, mdiWebhook } from '@mdi/js'
|
||||
|
||||
import { pluralize } from '@sourcegraph/common'
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
@ -62,7 +62,6 @@ export const OutboundWebhooksPage: FC<OutboundWebhooksPageProps> = ({ telemetryS
|
||||
<SummaryContainer centered={true}>
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={false}
|
||||
first={connection.totalCount ?? 0}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="webhook"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { type FC, useMemo } from 'react'
|
||||
import { useMemo, type FC } from 'react'
|
||||
|
||||
import { mdiAlertCircle, mdiMapSearch } from '@mdi/js'
|
||||
import classNames from 'classnames'
|
||||
@ -77,7 +77,6 @@ export const Logs: FC<LogsProps> = ({ id }) => {
|
||||
<SummaryContainer>
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={false}
|
||||
first={connection.totalCount ?? 0}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="webhook log"
|
||||
|
||||
@ -60,8 +60,6 @@ export const useOutboundWebhookLogsConnection = (
|
||||
useShowMorePagination<OutboundWebhookLogsResult, OutboundWebhookLogsVariables, OutboundWebhookLogFields>({
|
||||
query: OUTBOUND_WEBHOOK_LOGS,
|
||||
variables: {
|
||||
first: 20,
|
||||
after: null,
|
||||
id,
|
||||
onlyErrors,
|
||||
},
|
||||
|
||||
@ -4,18 +4,18 @@ import { mdiAccountMultiple, mdiPlus } from '@mdi/js'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import { type TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Button, Link, Icon, PageHeader, Container, useDebounce, ProductStatusBadge } from '@sourcegraph/wildcard'
|
||||
import { Button, Container, Icon, Link, PageHeader, ProductStatusBadge, useDebounce } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { UseShowMorePaginationResult } from '../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
ConnectionError,
|
||||
ConnectionLoading,
|
||||
ConnectionForm,
|
||||
ConnectionList,
|
||||
SummaryContainer,
|
||||
ConnectionLoading,
|
||||
ConnectionSummary,
|
||||
ShowMoreButton,
|
||||
ConnectionForm,
|
||||
SummaryContainer,
|
||||
} from '../../components/FilteredConnection/ui'
|
||||
import { Page } from '../../components/Page'
|
||||
import { PageTitle } from '../../components/PageTitle'
|
||||
@ -153,7 +153,6 @@ export const TeamList: React.FunctionComponent<TeamListProps> = ({
|
||||
{connection && (
|
||||
<SummaryContainer className="mt-2">
|
||||
<ConnectionSummary
|
||||
first={15}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="team"
|
||||
|
||||
@ -80,8 +80,6 @@ export function useTeams(search: string | null): UseShowMorePaginationResult<Lis
|
||||
return useShowMorePagination<ListTeamsResult, ListTeamsVariables, ListTeamFields>({
|
||||
query: LIST_TEAMS,
|
||||
variables: {
|
||||
after: null,
|
||||
first: 15,
|
||||
search,
|
||||
},
|
||||
options: {
|
||||
@ -99,8 +97,6 @@ export function useChildTeams(
|
||||
query: LIST_TEAMS_OF_PARENT,
|
||||
variables: {
|
||||
teamName: parentTeam,
|
||||
after: null,
|
||||
first: 15,
|
||||
search,
|
||||
},
|
||||
options: {
|
||||
|
||||
@ -8,12 +8,12 @@ import { Button, Container, Icon, useDebounce } from '@sourcegraph/wildcard'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
ConnectionError,
|
||||
ConnectionLoading,
|
||||
ConnectionForm,
|
||||
ConnectionList,
|
||||
SummaryContainer,
|
||||
ConnectionLoading,
|
||||
ConnectionSummary,
|
||||
ShowMoreButton,
|
||||
ConnectionForm,
|
||||
SummaryContainer,
|
||||
} from '../../components/FilteredConnection/ui'
|
||||
import type { Scalars } from '../../graphql-operations'
|
||||
|
||||
@ -91,7 +91,6 @@ export const TeamMemberListPage: React.FunctionComponent<React.PropsWithChildren
|
||||
{connection && (
|
||||
<SummaryContainer className="mt-2">
|
||||
<ConnectionSummary
|
||||
first={15}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="member"
|
||||
|
||||
@ -57,8 +57,6 @@ export function useTeamMembers(
|
||||
return useShowMorePagination<ListTeamMembersResult, ListTeamMembersVariables, ListTeamMemberFields>({
|
||||
query: LIST_TEAM_MEMBERS,
|
||||
variables: {
|
||||
after: null,
|
||||
first: 15,
|
||||
search,
|
||||
teamName,
|
||||
},
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import React, { useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
import { mdiPlus } from '@mdi/js'
|
||||
import { type Observable, Subject } from 'rxjs'
|
||||
import { Subject, type Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import { Container, PageHeader, Button, Icon, Text, ButtonLink, Tooltip } from '@sourcegraph/wildcard'
|
||||
import { Button, ButtonLink, Container, Icon, PageHeader, Text, Tooltip } from '@sourcegraph/wildcard'
|
||||
|
||||
import { requestGraphQL } from '../../../backend/graphql'
|
||||
import { FilteredConnection } from '../../../components/FilteredConnection'
|
||||
@ -20,8 +20,8 @@ import type {
|
||||
CreateAccessTokenResult,
|
||||
} from '../../../graphql-operations'
|
||||
import {
|
||||
accessTokenFragment,
|
||||
AccessTokenNode,
|
||||
accessTokenFragment,
|
||||
type AccessTokenNodeProps,
|
||||
} from '../../../settings/tokens/AccessTokenNode'
|
||||
import type { UserSettingsAreaRouteContext } from '../UserSettingsArea'
|
||||
@ -74,7 +74,7 @@ export const UserSettingsTokensPage: React.FunctionComponent<React.PropsWithChil
|
||||
}, [accessTokenUpdates])
|
||||
|
||||
const queryUserAccessTokens = useCallback(
|
||||
(args: { first?: number }) => queryAccessTokens({ first: args.first ?? null, user: user.id }),
|
||||
(args: { first?: number | null }) => queryAccessTokens({ first: args.first ?? null, user: user.id }),
|
||||
[user.id]
|
||||
)
|
||||
|
||||
|
||||
@ -300,5 +300,5 @@ func TestAccessRequestConnectionStore(t *testing.T) {
|
||||
db: db,
|
||||
}
|
||||
|
||||
graphqlutil.TestConnectionResolverStoreSuite(t, connectionStore)
|
||||
graphqlutil.TestConnectionResolverStoreSuite(t, connectionStore, nil)
|
||||
}
|
||||
|
||||
@ -260,10 +260,7 @@ func (p *ConnectionPageInfo[N]) EndCursor() (cursor *string, err error) {
|
||||
if len(p.nodes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cursor, err = p.store.MarshalCursor(p.nodes[len(p.nodes)-1], p.orderBy)
|
||||
|
||||
return
|
||||
return p.store.MarshalCursor(p.nodes[len(p.nodes)-1], p.orderBy)
|
||||
}
|
||||
|
||||
// StartCursor returns value for connection.pageInfo.startCursor and is called by the graphql api.
|
||||
@ -271,10 +268,7 @@ func (p *ConnectionPageInfo[N]) StartCursor() (cursor *string, err error) {
|
||||
if len(p.nodes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cursor, err = p.store.MarshalCursor(p.nodes[0], p.orderBy)
|
||||
|
||||
return
|
||||
return p.store.MarshalCursor(p.nodes[0], p.orderBy)
|
||||
}
|
||||
|
||||
// NewConnectionResolver returns a new connection resolver built using the store and connection args.
|
||||
@ -303,12 +297,28 @@ type TB interface {
|
||||
Fatalf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// TestPaginationArgs is a subset of database.PaginationArgs that can be passed as the base
|
||||
// pagination args to TestConnectionResolverStoreSuite.
|
||||
type TestPaginationArgs struct {
|
||||
OrderBy database.OrderBy
|
||||
Ascending bool
|
||||
}
|
||||
|
||||
// TestConnectionResolverStoreSuite can be used in tests to verify that a ConnectionResolverStore
|
||||
// implements the interface correctly.
|
||||
// This test makes the following assumptions:
|
||||
// - There are at least 10 records total in the connection
|
||||
func TestConnectionResolverStoreSuite[N any](t TB, store ConnectionResolverStore[N]) {
|
||||
func TestConnectionResolverStoreSuite[N any](t TB, store ConnectionResolverStore[N], testPaginationArgs *TestPaginationArgs) {
|
||||
pgArgs := func(args database.PaginationArgs) *database.PaginationArgs {
|
||||
if testPaginationArgs != nil {
|
||||
args.OrderBy = testPaginationArgs.OrderBy
|
||||
args.Ascending = testPaginationArgs.Ascending
|
||||
}
|
||||
return &args
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
total, err := store.ComputeTotal(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to compute total: %v", err)
|
||||
@ -317,54 +327,55 @@ func TestConnectionResolverStoreSuite[N any](t TB, store ConnectionResolverStore
|
||||
t.Fatalf("total is less than 10, please create at least 10 entities for this test suite. Have=%d", total)
|
||||
}
|
||||
// Basic case: Getting all without any limits works.
|
||||
allNodes, err := store.ComputeNodes(ctx, &database.PaginationArgs{})
|
||||
allNodes, err := store.ComputeNodes(ctx, pgArgs(database.PaginationArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list all nodes: %v", err)
|
||||
}
|
||||
// Check that all nodes were actually returned.
|
||||
if len(allNodes) != int(total) {
|
||||
t.Fatal("wrong number of nodes returned. want=%d, have=%d", total, len(allNodes))
|
||||
if got, want := len(allNodes), int(total); got != want {
|
||||
t.Fatalf("got %d nodes, want %d", got, want)
|
||||
}
|
||||
|
||||
// Pagination tests:
|
||||
// Check that first is properly working:
|
||||
for i := range int(total) {
|
||||
page, err := store.ComputeNodes(ctx, &database.PaginationArgs{First: pointers.Ptr(i + 1)})
|
||||
page, err := store.ComputeNodes(ctx, pgArgs(database.PaginationArgs{First: pointers.Ptr(i + 1)}))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list page nodes: %v", err)
|
||||
}
|
||||
// Check that all nodes were actually returned.
|
||||
if len(page) != i+1 {
|
||||
t.Fatal("wrong number of nodes returned. want=%d, have=%d", i+1, len(allNodes))
|
||||
if got, want := len(page), i+1; got != want {
|
||||
t.Fatalf("got %d nodes, want %d", got, want)
|
||||
}
|
||||
}
|
||||
// Check that last is properly working:
|
||||
for i := range int(total) {
|
||||
page, err := store.ComputeNodes(ctx, &database.PaginationArgs{Last: pointers.Ptr(i + 1)})
|
||||
page, err := store.ComputeNodes(ctx, pgArgs(database.PaginationArgs{Last: pointers.Ptr(i + 1)}))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list page nodes: %v", err)
|
||||
}
|
||||
// Check that all nodes were actually returned.
|
||||
if len(page) != i+1 {
|
||||
t.Fatal("wrong number of nodes returned. want=%d, have=%d", i+1, len(allNodes))
|
||||
if got, want := len(page), i+1; got != want {
|
||||
t.Fatalf("got %d nodes, want %d", got, want)
|
||||
}
|
||||
}
|
||||
// Check that first with cursor is properly working:
|
||||
currentCursor := []any{}
|
||||
for range int(total) {
|
||||
page, err := store.ComputeNodes(ctx, &database.PaginationArgs{First: pointers.Ptr(1), After: currentCursor})
|
||||
pgArgs := pgArgs(database.PaginationArgs{First: pointers.Ptr(1), After: currentCursor})
|
||||
page, err := store.ComputeNodes(ctx, pgArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list page nodes: %v", err)
|
||||
}
|
||||
// Check that exactly one node was returned.
|
||||
if len(page) != 1 {
|
||||
t.Fatal("wrong number of nodes returned. want=%d, have=%d", 1, len(allNodes))
|
||||
if got, want := len(page), 1; got != want {
|
||||
t.Fatalf("got %d nodes, want %d", got, want)
|
||||
}
|
||||
encodedCursor, err := store.MarshalCursor(page[0], nil)
|
||||
encodedCursor, err := store.MarshalCursor(page[0], pgArgs.OrderBy)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal cursor: %v", err)
|
||||
}
|
||||
currentCursor, err = store.UnmarshalCursor(*encodedCursor, nil)
|
||||
currentCursor, err = store.UnmarshalCursor(*encodedCursor, pgArgs.OrderBy)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal cursor: %v", err)
|
||||
}
|
||||
@ -372,19 +383,20 @@ func TestConnectionResolverStoreSuite[N any](t TB, store ConnectionResolverStore
|
||||
// Check that last with cursor is properly working:
|
||||
currentCursor = []any{}
|
||||
for range int(total) {
|
||||
page, err := store.ComputeNodes(ctx, &database.PaginationArgs{Last: pointers.Ptr(1), Before: currentCursor})
|
||||
pgArgs := pgArgs(database.PaginationArgs{Last: pointers.Ptr(1), Before: currentCursor})
|
||||
page, err := store.ComputeNodes(ctx, pgArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list page nodes: %v", err)
|
||||
}
|
||||
// Check that exactly one node was returned.
|
||||
if len(page) != 1 {
|
||||
t.Fatal("wrong number of nodes returned. want=%d, have=%d", 1, len(allNodes))
|
||||
if got, want := len(page), 1; got != want {
|
||||
t.Fatalf("got %d nodes, want %d", got, want)
|
||||
}
|
||||
encodedCursor, err := store.MarshalCursor(page[0], nil)
|
||||
encodedCursor, err := store.MarshalCursor(page[0], pgArgs.OrderBy)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal cursor: %v", err)
|
||||
}
|
||||
currentCursor, err = store.UnmarshalCursor(*encodedCursor, nil)
|
||||
currentCursor, err = store.UnmarshalCursor(*encodedCursor, pgArgs.OrderBy)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal cursor: %v", err)
|
||||
}
|
||||
|
||||
@ -499,5 +499,5 @@ func TestMembersConnectionStore(t *testing.T) {
|
||||
orgID: org.ID,
|
||||
}
|
||||
|
||||
graphqlutil.TestConnectionResolverStoreSuite(t, connectionStore)
|
||||
graphqlutil.TestConnectionResolverStoreSuite(t, connectionStore, nil)
|
||||
}
|
||||
|
||||
@ -4,12 +4,13 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
|
||||
"github.com/sourcegraph/sourcegraph/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
rawCursor = types.Cursor{Column: "foo", Value: "bar", Direction: "next"}
|
||||
opaqueCursor = "UmVwb3NpdG9yeUN1cnNvcjp7IkNvbHVtbiI6ImZvbyIsIlZhbHVlIjoiYmFyIiwiRGlyZWN0aW9uIjoibmV4dCJ9"
|
||||
opaqueCursor = "UmVwb3NpdG9yeUN1cnNvcjp7ImMiOiJmb28iLCJ2IjoiYmFyIiwiZCI6Im5leHQifQ=="
|
||||
)
|
||||
|
||||
func TestMarshalRepositoryCursor(t *testing.T) {
|
||||
|
||||
@ -578,5 +578,5 @@ func TestSavedSearchesConnectionStore(t *testing.T) {
|
||||
userID: &user.ID,
|
||||
}
|
||||
|
||||
graphqlutil.TestConnectionResolverStoreSuite(t, connectionStore)
|
||||
graphqlutil.TestConnectionResolverStoreSuite(t, connectionStore, nil)
|
||||
}
|
||||
|
||||
@ -211,7 +211,7 @@ func TestListWebhooks(t *testing.T) {
|
||||
{"id":"V2ViaG9vazoy"}
|
||||
],
|
||||
"totalCount":2,
|
||||
"pageInfo":{"hasNextPage":true, "endCursor": "V2ViaG9va0N1cnNvcjp7IkNvbHVtbiI6ImlkIiwiVmFsdWUiOiIzIiwiRGlyZWN0aW9uIjoibmV4dCJ9"}
|
||||
"pageInfo":{"hasNextPage":true, "endCursor": "V2ViaG9va0N1cnNvcjp7ImMiOiJpZCIsInYiOiIzIiwiZCI6Im5leHQifQ=="}
|
||||
}}`,
|
||||
},
|
||||
{
|
||||
@ -254,7 +254,7 @@ func TestListWebhooks(t *testing.T) {
|
||||
`,
|
||||
Variables: map[string]any{
|
||||
"first": 2,
|
||||
"after": "V2ViaG9va0N1cnNvcjp7IkNvbHVtbiI6ImlkIiwiVmFsdWUiOiIzIiwiRGlyZWN0aW9uIjoibmV4dCJ9",
|
||||
"after": "V2ViaG9va0N1cnNvcjp7ImMiOiJpZCIsInYiOiIzIiwiZCI6Im5leHQifQ==",
|
||||
},
|
||||
ExpectedResult: `{"webhooks":
|
||||
{
|
||||
@ -376,7 +376,7 @@ func TestWebhooks_CursorPagination(t *testing.T) {
|
||||
"id": "V2ViaG9vazow"
|
||||
}],
|
||||
"pageInfo": {
|
||||
"endCursor": "V2ViaG9va0N1cnNvcjp7IkNvbHVtbiI6ImlkIiwiVmFsdWUiOiIxIiwiRGlyZWN0aW9uIjoibmV4dCJ9"
|
||||
"endCursor": "V2ViaG9va0N1cnNvcjp7ImMiOiJpZCIsInYiOiIxIiwiZCI6Im5leHQifQ=="
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -390,7 +390,7 @@ func TestWebhooks_CursorPagination(t *testing.T) {
|
||||
|
||||
graphqlbackend.RunTest(t, &graphqlbackend.Test{
|
||||
Schema: createGqlSchema(t, db),
|
||||
Query: buildQuery(1, "V2ViaG9va0N1cnNvcjp7IkNvbHVtbiI6ImlkIiwiVmFsdWUiOiIxIiwiRGlyZWN0aW9uIjoibmV4dCJ9"),
|
||||
Query: buildQuery(1, "V2ViaG9va0N1cnNvcjp7ImMiOiJpZCIsInYiOiIyIiwiZCI6Im5leHQifQ=="),
|
||||
ExpectedResult: `
|
||||
{
|
||||
"webhooks": {
|
||||
@ -398,7 +398,7 @@ func TestWebhooks_CursorPagination(t *testing.T) {
|
||||
"id": "V2ViaG9vazox"
|
||||
}],
|
||||
"pageInfo": {
|
||||
"endCursor": "V2ViaG9va0N1cnNvcjp7IkNvbHVtbiI6ImlkIiwiVmFsdWUiOiIyIiwiRGlyZWN0aW9uIjoibmV4dCJ9"
|
||||
"endCursor": "V2ViaG9va0N1cnNvcjp7ImMiOiJpZCIsInYiOiIyIiwiZCI6Im5leHQifQ=="
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -667,7 +667,7 @@ func TestGetWebhookWithURL(t *testing.T) {
|
||||
func TestWebhookCursor(t *testing.T) {
|
||||
var (
|
||||
webhookCursor = types.Cursor{Column: "id", Value: "4", Direction: "next"}
|
||||
opaqueWebhookCursor = "V2ViaG9va0N1cnNvcjp7IkNvbHVtbiI6ImlkIiwiVmFsdWUiOiI0IiwiRGlyZWN0aW9uIjoibmV4dCJ9"
|
||||
opaqueWebhookCursor = "V2ViaG9va0N1cnNvcjp7ImMiOiJpZCIsInYiOiI0IiwiZCI6Im5leHQifQ=="
|
||||
)
|
||||
t.Run("Marshal", func(t *testing.T) {
|
||||
if got, want := MarshalWebhookCursor(&webhookCursor), opaqueWebhookCursor; got != want {
|
||||
|
||||
@ -96,6 +96,11 @@ func (o OrderBy) SQL(ascending bool) *sqlf.Query {
|
||||
}
|
||||
|
||||
// OrderByOption represents ordering in SQL by one column.
|
||||
//
|
||||
// The direction (ascending or descending) is not set here. It is set in (PaginationArgs).Ascending.
|
||||
// This is because we use [PostgreSQL composite
|
||||
// types](https://www.postgresql.org/docs/current/rowtypes.html) to support before/after pagination
|
||||
// cursors based on multiple columns.
|
||||
type OrderByOption struct {
|
||||
Field string
|
||||
Nulls OrderByNulls
|
||||
@ -150,7 +155,7 @@ func (p *PaginationArgs) SQL() *QueryArgs {
|
||||
orderByColumns := orderBy.Columns()
|
||||
|
||||
if len(p.After) > 0 {
|
||||
// For order by stars, id this'll generate SQL of the following form:
|
||||
// For "order by stars, id" this'll generate SQL of the following form:
|
||||
// WHERE (stars, id) (<|>) (%s, %s)
|
||||
// ORDER BY stars (ASC|DESC), id (ASC|DESC)
|
||||
columnsStr := strings.Join(orderByColumns, ", ")
|
||||
@ -182,7 +187,7 @@ func (p *PaginationArgs) SQL() *QueryArgs {
|
||||
}
|
||||
|
||||
if len(conditions) > 0 {
|
||||
queryArgs.Where = sqlf.Sprintf("%v", sqlf.Join(conditions, "AND "))
|
||||
queryArgs.Where = sqlf.Join(conditions, "AND ")
|
||||
}
|
||||
|
||||
if p.First != nil {
|
||||
|
||||
@ -7,9 +7,9 @@ type MultiCursor []*Cursor
|
||||
// A Cursor for efficient index based pagination through large result sets.
|
||||
type Cursor struct {
|
||||
// Columns contains the relevant columns for cursor-based pagination (e.g. "name")
|
||||
Column string
|
||||
Column string `json:"c"`
|
||||
// Value contains the relevant value for cursor-based pagination (e.g. "Zaphod").
|
||||
Value string
|
||||
Value string `json:"v"`
|
||||
// Direction contains the comparison for cursor-based pagination, all possible values are: next, prev.
|
||||
Direction string
|
||||
Direction string `json:"d"`
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import path from 'path'
|
||||
|
||||
import { type UserConfig, type UserWorkspaceConfig, defineProject, mergeConfig } from 'vitest/config'
|
||||
import { defineProject, mergeConfig, type UserConfig, type UserWorkspaceConfig } from 'vitest/config'
|
||||
|
||||
/** Whether we're running in Bazel. */
|
||||
export const BAZEL = !!process.env.BAZEL
|
||||
|
||||
Loading…
Reference in New Issue
Block a user