Merge branch 'main' into es/07-08-gatingaddindividualswitchesfordisablingtoolsfeatures

This commit is contained in:
Stefan Hengl 2024-07-16 10:26:10 +02:00 committed by GitHub
commit c9bccb5955
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
288 changed files with 8638 additions and 6926 deletions

View File

@ -94,6 +94,7 @@ const config = {
'@typescript-eslint/no-unused-vars': 'off', // also duplicated by tsconfig noUnused{Locals,Parameters}
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'etc/no-deprecated': 'off',
'no-restricted-imports': [

View File

@ -3,10 +3,10 @@ import { describe, expect, test } from 'vitest'
import { createAggregateError, isErrorLike } from '@sourcegraph/common'
import {
type CustomMergeFunctions,
gqlToCascade,
merge,
mergeSettings,
type CustomMergeFunctions,
type Settings,
type SettingsCascade,
type SettingsSubject,
@ -198,7 +198,6 @@ describe('mergeSettings', () => {
key: '1',
description: 'global saved query',
query: 'type:diff global',
notify: true,
},
],
},
@ -208,7 +207,6 @@ describe('mergeSettings', () => {
key: '2',
description: 'org saved query',
query: 'type:diff org',
notify: true,
},
],
},
@ -218,7 +216,6 @@ describe('mergeSettings', () => {
key: '3',
description: 'user saved query',
query: 'type:diff user',
notify: true,
},
],
},
@ -229,19 +226,16 @@ describe('mergeSettings', () => {
key: '1',
description: 'global saved query',
query: 'type:diff global',
notify: true,
},
{
key: '2',
description: 'org saved query',
query: 'type:diff org',
notify: true,
},
{
key: '3',
description: 'user saved query',
query: 'type:diff user',
notify: true,
},
],
}))

View File

@ -2,7 +2,6 @@ import ResizeObserver from 'resize-observer-polyfill'
import { vi } from 'vitest'
if ('ResizeObserver' in window === false) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.ResizeObserver = ResizeObserver
}

View File

@ -2,5 +2,8 @@ const baseConfig = require('../../prettier.config.js')
module.exports = {
...baseConfig,
plugins: [...(baseConfig.plugins || []), 'prettier-plugin-svelte'],
overrides: [...(baseConfig.overrides || []), { files: '*.svelte', options: { parser: 'svelte', htmlWhitespaceSensitivity: 'strict' } }],
overrides: [
...(baseConfig.overrides || []),
{ files: '*.svelte', options: { parser: 'svelte', htmlWhitespaceSensitivity: 'strict' } },
],
}

View File

@ -1,215 +1,192 @@
import { type AnyVariables, Client, type OperationResult, CombinedError, cacheExchange } from '@urql/core'
import { test, expect, vi, beforeEach } from 'vitest'
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
import { pipe, filter, map, merge } from 'wonka'
import { infinityQuery } from './urql'
import { IncrementalRestoreStrategy, infinityQuery } from './urql'
function getMockClient(responses: Partial<OperationResult<any, AnyVariables>>[]): Client {
return new Client({
url: '#testingonly',
exchanges: [
cacheExchange, // This is required because infiniteQuery expects that a cache exchange is present
({ forward }) =>
operations$ => {
const mockResults$ = pipe(
operations$,
filter(operation => {
switch (operation.kind) {
case 'query':
case 'mutation':
return true
default:
return false
}
}),
map((operation): OperationResult<any, AnyVariables> => {
const response = responses.shift()
if (!response) {
describe('infinityQuery', () => {
function getMockClient(responses: Partial<OperationResult<any, AnyVariables>>[]): Client {
return new Client({
url: '#testingonly',
exchanges: [
cacheExchange, // This is required because infiniteQuery expects that a cache exchange is present
({ forward }) =>
operations$ => {
const mockResults$ = pipe(
operations$,
filter(operation => {
switch (operation.kind) {
case 'query':
case 'mutation':
return true
default:
return false
}
}),
map((operation): OperationResult<any, AnyVariables> => {
const response = responses.shift()
if (!response) {
return {
operation,
error: new CombinedError({
networkError: new Error('No more responses'),
}),
stale: false,
hasNext: false,
}
}
return {
...response,
operation,
error: new CombinedError({
networkError: new Error('No more responses'),
}),
data: response.data ?? undefined,
error: response.error ?? undefined,
stale: false,
hasNext: false,
}
}
return {
...response,
operation,
data: response.data ?? undefined,
error: response.error ?? undefined,
stale: false,
hasNext: false,
}
})
)
})
)
const forward$ = pipe(
operations$,
filter(operation => {
switch (operation.kind) {
case 'query':
case 'mutation':
return false
default:
return true
}
}),
forward
)
const forward$ = pipe(
operations$,
filter(operation => {
switch (operation.kind) {
case 'query':
case 'mutation':
return false
default:
return true
}
}),
forward
)
return merge([mockResults$, forward$])
},
],
})
}
return merge([mockResults$, forward$])
},
],
})
}
function getQuery(client: Client) {
return infinityQuery({
client,
query: 'query { list { nodes { id } } pageInfo { hasNextPage, endCursor } } }',
variables: {
first: 2,
afterCursor: null as string | null,
},
nextVariables: previousResult => {
if (previousResult?.data?.list?.pageInfo?.hasNextPage) {
function getQuery(client: Client) {
return infinityQuery({
client,
query: 'query { list { nodes { id } } pageInfo { hasNextPage, endCursor } } }',
variables: {
first: 2,
afterCursor: null as string | null,
},
map: result => {
const list = result.data?.list
return {
afterCursor: previousResult.data.list.pageInfo.endCursor,
nextVariables: list?.pageInfo.hasNextPage ? { afterCursor: list.pageInfo.endCursor } : undefined,
data: list?.nodes,
error: result.error,
}
}
return undefined
},
combine: (previousResult, nextResult) => {
if (!nextResult.data?.list) {
return nextResult
}
const previousNodes = previousResult.data?.list?.nodes ?? []
const nextNodes = nextResult.data.list?.nodes ?? []
return {
...nextResult,
},
merge: (prev, next) => [...(prev ?? []), ...(next ?? [])],
createRestoreStrategy: api =>
new IncrementalRestoreStrategy(
api,
n => n.length,
n => ({ first: n.length })
),
})
}
let query: ReturnType<typeof getQuery>
beforeEach(() => {
vi.useFakeTimers()
const client = getMockClient([
{
data: {
list: {
...nextResult.data.list,
nodes: [...previousNodes, ...nextNodes],
},
},
}
},
})
}
let query: ReturnType<typeof infinityQuery>
beforeEach(() => {
vi.useFakeTimers()
const client = getMockClient([
{
data: {
list: {
nodes: [{ id: 1 }, { id: 2 }],
pageInfo: {
hasNextPage: true,
endCursor: '2',
nodes: [{ id: 1 }, { id: 2 }],
pageInfo: {
hasNextPage: true,
endCursor: '2',
},
},
},
},
},
{
data: {
list: {
nodes: [{ id: 3 }, { id: 4 }],
pageInfo: {
hasNextPage: true,
endCursor: '4',
{
data: {
list: {
nodes: [{ id: 3 }, { id: 4 }],
pageInfo: {
hasNextPage: true,
endCursor: '4',
},
},
},
},
},
{
data: {
list: {
nodes: [{ id: 5 }, { id: 6 }],
pageInfo: {
hasNextPage: false,
{
data: {
list: {
nodes: [{ id: 5 }, { id: 6 }],
pageInfo: {
hasNextPage: false,
},
},
},
},
},
])
query = getQuery(client)
})
test('fetch more', async () => {
const subscribe = vi.fn()
query.subscribe(subscribe)
await vi.runAllTimersAsync()
// 1. call: fetching -> true
// 2. call: result
expect(subscribe).toHaveBeenCalledTimes(2)
expect(subscribe.mock.calls[0][0]).toMatchObject({
fetching: true,
])
query = getQuery(client)
})
expect(subscribe.mock.calls[1][0]).toMatchObject({
fetching: false,
data: {
list: {
nodes: [{ id: 1 }, { id: 2 }],
pageInfo: {
hasNextPage: true,
endCursor: '2',
},
afterEach(() => {
vi.useRealTimers()
})
test('fetch more', async () => {
const subscribe = vi.fn()
query.subscribe(subscribe)
await vi.runAllTimersAsync()
// 1. call: fetching -> true
// 2. call: result
expect(subscribe).toHaveBeenCalledTimes(2)
expect(subscribe.mock.calls[0][0]).toMatchObject({
fetching: true,
})
expect(subscribe.mock.calls[1][0]).toMatchObject({
fetching: false,
data: [{ id: 1 }, { id: 2 }],
nextVariables: {
afterCursor: '2',
},
},
})
})
// Fetch more data
query.fetchMore()
await vi.runAllTimersAsync()
// Fetch more data
query.fetchMore()
await vi.runAllTimersAsync()
// 3. call: fetching -> true
// 4. call: result
expect(subscribe).toHaveBeenCalledTimes(4)
expect(subscribe.mock.calls[2][0]).toMatchObject({
fetching: true,
})
expect(subscribe.mock.calls[3][0]).toMatchObject({
fetching: false,
data: {
list: {
nodes: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }],
pageInfo: {
hasNextPage: true,
endCursor: '4',
},
// 3. call: fetching -> true
// 4. call: result
expect(subscribe).toHaveBeenCalledTimes(4)
expect(subscribe.mock.calls[2][0]).toMatchObject({
fetching: true,
})
expect(subscribe.mock.calls[3][0]).toMatchObject({
fetching: false,
data: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }],
nextVariables: {
afterCursor: '4',
},
},
})
})
test('restoring state', async () => {
const subscribe = vi.fn()
query.subscribe(subscribe)
await vi.runAllTimersAsync()
await query.restore(result => (result.data as any).list.nodes.length < 5)
expect(subscribe).toHaveBeenCalledTimes(6)
expect(subscribe.mock.calls[4][0]).toMatchObject({
restoring: true,
})
expect(subscribe.mock.calls[5][0]).toMatchObject({
restoring: false,
data: {
list: {
nodes: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }],
pageInfo: {
hasNextPage: false,
},
},
},
})
})
test('restoring state', async () => {
const subscribe = vi.fn()
query.subscribe(subscribe)
const snapshot = query.capture()
query.restore({ ...snapshot!, count: 6 })
await vi.runAllTimersAsync()
expect(subscribe.mock.calls[3][0]).toMatchObject({
fetching: false,
data: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }],
})
})
})

View File

@ -2,22 +2,20 @@ import {
Client,
cacheExchange,
fetchExchange,
mapExchange,
type Exchange,
makeOperation,
mapExchange,
type AnyVariables,
type OperationResult,
createRequest,
type DocumentInput,
type Exchange,
type OperationResult,
} from '@urql/core'
import type { OperationDefinitionNode } from 'graphql'
import { once } from 'lodash'
import { from, isObservable, Subject, type Observable, concat, of } from 'rxjs'
import { map, switchMap, scan, startWith } from 'rxjs/operators'
import { type Readable, readable, get } from 'svelte/store'
import { type Readable, get, writable, type Writable } from 'svelte/store'
import type { GraphQLResult } from '@sourcegraph/http-client'
import { uniqueID } from '$lib/dom'
import { GRAPHQL_URI } from '$lib/http-client'
import { getHeaders } from './shared'
@ -65,7 +63,7 @@ export function query<TData = any, TVariables extends AnyVariables = AnyVariable
return getGraphQLClient().query<TData, TVariables>(query, variables).toPromise()
}
interface InfinityQueryArgs<TData = any, TVariables extends AnyVariables = AnyVariables> {
interface InfinityQueryArgs<TData, TPayload = any, TVariables extends AnyVariables = AnyVariables, TSnapshot = any> {
/**
* The {@link Client} instance to use for the query.
*/
@ -74,76 +72,112 @@ interface InfinityQueryArgs<TData = any, TVariables extends AnyVariables = AnyVa
/**
* The GraphQL query to execute.
*/
query: DocumentInput<TData, TVariables>
query: DocumentInput<TPayload, TVariables>
/**
* The initial variables to use for the query.
*/
variables: TVariables | Observable<TVariables>
variables: TVariables | Promise<TVariables>
/**
* A function that returns the next set of variables to use for the query.
* Process the result of the query. This function maps the response to the data used
* and computes the next set of query variables, if any.
*
* @param result - The result of the query.
* @param previousResult - The previous result of the query.
*
* @remarks
* `nextVariables` is called when {@link InfinityQueryStore.fetchMore} is called to get the next set
* of variables to fetch the next page of data. This function to extract the cursor for the next
* page from the previous result.
* @returns The new/combined result state.
*/
nextVariables: (previousResult: OperationResult<TData, TVariables>) => Partial<TVariables> | undefined
map: (result: OperationResult<TPayload, TVariables>) => InfinityStoreResult<TData, TVariables>
/**
* A function to combine the previous result with the next result.
*
* @param previousResult - The previous result of the query.
* @param nextResult - The next result of the query.
* @returns The combined result of the query.
*
* @remarks
* `combine` is called when the next result is received to merge the previous result with the new
* result. This function is used to append the new data to the previous data.
* Optional callback to merge the data from the previous result with the new data.
* If not provided the new data will replace the old data.
*/
combine: (
previousResult: OperationResultState<TData, TVariables>,
nextResult: OperationResultState<TData, TVariables>
) => OperationResultState<TData, TVariables>
merge?: (previousData: TData | undefined, newData: TData | undefined) => TData
/**
* Returns a strategy for restoring the data when navigating back to a page.
*/
createRestoreStrategy?: (api: InfinityAPI<TData, TVariables>) => RestoreStrategy<TSnapshot, TData>
}
interface OperationResultState<TData = any, TVariables extends AnyVariables = AnyVariables>
extends OperationResult<TData, TVariables> {
/**
* Internal API for the infinity query store. Used by restore strategies to control the store.
*/
interface InfinityAPI<TData, TVariables extends AnyVariables = AnyVariables> {
/**
* The internal store representing the current state of the query.
*/
store: Writable<InfinityStoreResultState<TData, TVariables>>
/**
* Helper function for fetching and processing the next set of data.
*/
fetch(
variables: Partial<TVariables>,
previous: InfinityStoreResult<TData, TVariables>
): Promise<InfinityStoreResult<TData, TVariables>>
}
/**
* The processed/combined result of a GraphQL query.
*/
export interface InfinityStoreResult<TData = any, TVariables extends AnyVariables = AnyVariables> {
data?: TData
/**
* Set if there was an error fetching the data. When set, no more data will be fetched.
*/
error?: Error
/**
* The set of variables to use for the next fetch. If not set no more data will be fetched.
*/
nextVariables?: Partial<TVariables>
}
/**
* The state of the infinity query store.
*/
interface InfinityStoreResultState<TData = any, TVariables extends AnyVariables = AnyVariables>
extends InfinityStoreResult<TData, TVariables> {
/**
* Whether a GraphQL request is currently in flight.
*/
fetching: boolean
/**
* Whether the store is currently restoring data.
*/
restoring: boolean
}
// This needs to be exported so that TS type inference can work in SvelteKit generated files.
export interface InfinityQueryStore<TData = any, TVariables extends AnyVariables = AnyVariables>
extends Readable<OperationResultState<TData, TVariables>> {
export interface InfinityQueryStore<TData = any, TVariables extends AnyVariables = AnyVariables, TSnapshot = any>
extends Readable<InfinityStoreResultState<TData, TVariables>> {
/**
* Reruns the query with the next set of variables returned by {@link InfinityQueryArgs.nextVariables}.
* Reruns the query with the next set of query variables.
*
* @remarks
* A new query will only be executed if there is no query currently in flight and {@link InfinityQueryArgs.nextVariables}
* returns a value different from `undefined`.
* A new query will only be executed if there is no query currently in flight and {@link InfinityStoreResult.nextVariables}
* is set.
*/
fetchMore: () => void
/**
* Fetches more data until the given restoreHandler returns `false`.
*
* @param restoreHandler - A function that returns `true` if more data should be fetched.
*
* @remarks
* When navigating back to a page that was previously fetched with `infinityQuery`, the page
* should call `restore` until the previous data state is restored.
* Fetches data while the given predicate is true. Using this function is different f
* rom calling {@link fetchMore} in a loop, because it will set/unset the fetching state
* only once.
*/
restore: (restoreHandler: (result: OperationResultState<TData, TVariables>) => boolean) => Promise<void>
fetchWhile: (predicate: (data: TData) => boolean) => Promise<void>
/**
* Restores the data state from a snapshot, which is returned by {@link capture}.
*
* @param snapshot - The snapshot to restore.
* @returns A promise that resolves when the data has been restored.
*/
restore: (snapshot: TSnapshot | undefined) => Promise<void>
/**
* Captures the current data state to a snapshot that can be used to restore the data later.
* @returns The snapshot.
*/
capture: () => TSnapshot | undefined
}
/**
@ -157,125 +191,216 @@ export interface InfinityQueryStore<TData = any, TVariables extends AnyVariables
* with the given {@link InfinityQueryArgs.variables}.
*
* The caller can call {@link InfinityQueryStore.fetchMore} to fetch more data. The store will
* call {@link InfinityQueryArgs.nextVariables} to get the next set of variables to use for the query
* and merge it into the initial variables.
* When the result is received, the store will call {@link InfinityQueryArgs.combine} to merge the
* previous result with the new result.
* call {@link InfinityQueryArgs.mapResult} to process the query result, combine it with the previous result
* and to compute the query variables for the next fetch, if any.
*
* Calling this function will prefetch the initial data, i.e. the data is fetch before the store is
* subscribed to.
*/
export function infinityQuery<TData = any, TVariables extends AnyVariables = AnyVariables>(
args: InfinityQueryArgs<TData, TVariables>
): InfinityQueryStore<TData, TVariables> {
// This is a hacky workaround to create an initialState. The empty object is
// invalid but the request will never be executed with these variables anyway.
const initialVariables = isObservable(args.variables) ? args.variables : of(args.variables)
const operation = args.client.createRequestOperation(
'query',
isObservable(args.variables)
? createRequest(args.query, {} as TVariables)
: createRequest(args.query, args.variables)
)
const initialState: OperationResultState<TData, TVariables> = {
operation,
error: undefined,
data: undefined,
extensions: undefined,
stale: false,
fetching: false,
restoring: false,
hasNext: false,
export function infinityQuery<
TData = any,
TPayload = any,
TVariables extends AnyVariables = AnyVariables,
TSnapshot = void
>(args: InfinityQueryArgs<TData, TPayload, TVariables, TSnapshot>): InfinityQueryStore<TData, TVariables, TSnapshot> {
const initialVariables = Promise.resolve(args.variables)
async function fetch(
variables: Partial<TVariables>,
previousResult: InfinityStoreResult<TData, TVariables>
): Promise<InfinityStoreResult<TData, TVariables>> {
const result = args.map(
await initialVariables.then(initialVariables =>
args.client.query(args.query, { ...initialVariables, ...variables })
)
)
if (args.merge) {
result.data = args.merge(previousResult.data, result.data)
}
return result
}
const nextVariables = new Subject<Partial<TVariables>>()
let shouldRestore: ((result: OperationResultState<TData, TVariables>) => boolean) | null = null
const initialState: InfinityStoreResultState<TData, TVariables> = { fetching: true }
const store = writable(initialState)
const restoreStrategy = args.createRestoreStrategy?.({ store, fetch })
// Prefetch data. We don't want to wait until the store is subscribed to. That allows us to use this function
// inside a data loader and the data will be prefetched before the component is rendered.
initialVariables.subscribe(variables => {
void args.client.query(args.query, variables).toPromise()
fetch({}, {}).then(result => {
store.update(current => {
// Only set the initial state if we haven't already started another fetch process,
// e.g. when restoring the state.
if (current === initialState) {
return { ...result, fetching: false }
}
return current
})
})
const result = readable(initialState, set => {
const subscription = initialVariables
.pipe(
switchMap(initialVariables =>
nextVariables.pipe(
startWith(initialVariables), // nextVaribles will not emit until the first fetchMore is called
switchMap(variables => {
const operation = args.client.createRequestOperation(
'query',
createRequest(args.query, { ...initialVariables, ...variables })
)
return concat(
of({ fetching: true, stale: false, restoring: false }),
from(args.client.executeRequestOperation(operation).toPromise()).pipe(
map(({ data, stale, operation, error, extensions }) => ({
fetching: false,
data,
stale: !!stale,
operation,
error,
extensions,
}))
)
)
})
)
),
scan((result, update) => {
const newResult = { ...result, ...update }
return update.fetching ? newResult : args.combine(result, newResult)
}, initialState)
)
.subscribe(result => {
if (shouldRestore) {
result.restoring = Boolean(
(result.data || result.error) && shouldRestore(result) && args.nextVariables(result)
)
/**
* Resolves when the store is not fetching anymore.
*/
function waitTillReady(): Promise<void> {
let unsubscribe: () => void
return new Promise<void>(resolve => {
unsubscribe = store.subscribe(current => {
if (!current.fetching) {
resolve()
}
set(result)
})
return () => subscription.unsubscribe()
})
}).finally(() => unsubscribe())
}
return {
...result,
subscribe: store.subscribe,
fetchMore: () => {
const current = get(result)
if (current.fetching || current.restoring) {
const previous = get(store)
if (previous.fetching) {
// When a fetch is already in progress, we don't want to start another one for the same variables.
return
}
const newVariables = args.nextVariables(current)
if (!newVariables) {
return
}
nextVariables.next(newVariables)
},
restore: restoreHandler => {
shouldRestore = result => {
return Boolean((result.data || result.error) && restoreHandler(result) && args.nextVariables(result))
}
return new Promise(resolve => {
const unsubscribe = result.subscribe(result => {
if (result.fetching) {
return
}
if (result.data || result.error) {
const newVariables = args.nextVariables(result)
if (restoreHandler(result) && newVariables) {
shouldRestore = restoreHandler
nextVariables.next(newVariables)
} else {
unsubscribe()
shouldRestore = null
resolve()
if (previous.nextVariables && !previous.error) {
store.set({ ...previous, fetching: true })
fetch(previous.nextVariables, previous).then(result => {
store.update(current => {
if (previous.nextVariables === current.nextVariables) {
return { ...result, fetching: false }
}
}
return current
})
})
})
}
},
fetchWhile: async predicate => {
// We need to wait until the store is not fetching anymore to ensure that we don't start
// another fetch process while one is already in progress.
await waitTillReady()
const current = get(store)
store.set({ ...current, fetching: true })
let result: InfinityStoreResult<TData, TVariables> = current
while (!result.error && result.nextVariables && result.data && predicate(result.data)) {
result = await fetch(result.nextVariables, result)
}
store.set({ ...result, fetching: false })
},
capture: () => restoreStrategy?.capture(get(store)),
restore: snapshot => {
if (restoreStrategy && snapshot) {
return restoreStrategy.restore(snapshot)
}
return Promise.resolve()
},
}
}
/**
* A restore strategy captures and restores the data state of a query.
*/
interface RestoreStrategy<TSnapshot, TData> {
capture(result: InfinityStoreResult<TData>): TSnapshot | undefined
restore(snapshot: TSnapshot): Promise<void>
}
// This needs to be exported so that TS type inference can work in SvelteKit generated files.
export interface IncrementalRestoreStrategySnapshot<TVariables extends AnyVariables> {
count: number
variables?: Partial<TVariables>
nonce: string
}
// We use this to indentify snapshots that were created in the current "session", which
// means there is a high chance that the data is still in the cache.
const NONCE = uniqueID('repeat-restore')
/**
* The incremental restore strategy captures and restores the data by counting the number of items.
* It will fetch more data until the count matches the snapshot count.
*
* This strategy is useful when every fetch returns a fixed number of items (i.e. after a cursor).
* In this case we want to make use of our GraphQL client's caching strategy and simply
* "replay" the previous fetches.
*
* This strategy works well when GraphQL requests are cached. To avoid waterfall requests in case the
* data is not cached, the strategy will fall back to requesting the data once with query variables
* from the snapshot.
*/
export class IncrementalRestoreStrategy<TData, TVariables extends AnyVariables>
implements RestoreStrategy<IncrementalRestoreStrategySnapshot<TVariables>, TData>
{
constructor(
private api: InfinityAPI<TData, TVariables>,
/**
* A function to map the data to a number. This number will be used to count the items.
*/
private mapper: (data: TData) => number,
/**
* A function to map the data to query variables. These variables will be used to fetch the data
* once when if there is a chance that the data is not in the cache (fallback).
*/
private variablesMapper?: (data: TData) => Partial<TVariables>
) {}
public capture(result: InfinityStoreResult<TData>): IncrementalRestoreStrategySnapshot<TVariables> | undefined {
return result.data
? {
count: this.mapper(result.data),
variables: this.variablesMapper ? this.variablesMapper(result.data) : undefined,
nonce: NONCE,
}
: undefined
}
public async restore(snapshot: IncrementalRestoreStrategySnapshot<TVariables>): Promise<void> {
this.api.store.set({ fetching: true })
const result = await (snapshot.nonce !== NONCE && snapshot.variables
? this.api.fetch(snapshot.variables, {})
: this.fetch(snapshot))
this.api.store.set({ ...result, fetching: false })
}
private async fetch(
snapshot: IncrementalRestoreStrategySnapshot<TVariables>
): Promise<InfinityStoreResult<TData, TVariables>> {
let current: InfinityStoreResult<TData, TVariables> = { nextVariables: {} }
while (current.nextVariables && ((current.data && this.mapper(current.data)) || 0) < snapshot.count) {
current = await this.api.fetch(current.nextVariables, current)
if (current.error || !current.data) {
break
}
}
return current
}
}
/**
* A restore strategy that overwrites the current store state with the response of a new query.
* The strategy uses the query variables form the snapshot to fetch the data.
*/
export class OverwriteRestoreStrategy<TData, TVariables extends AnyVariables>
implements RestoreStrategy<{ variables: Partial<TVariables> }, TData>
{
constructor(
private api: InfinityAPI<TData, TVariables>,
private variablesMapper: (data: TData) => Partial<TVariables>
) {}
capture(result: InfinityStoreResult<TData, TVariables>): { variables: Partial<TVariables> } | undefined {
if (!result.data) {
return undefined
}
const variables = this.variablesMapper(result.data)
return variables ? { variables } : undefined
}
async restore(snapshot: { variables: Partial<TVariables> }): Promise<void> {
this.api.store.set({ fetching: true })
const result = await this.api.fetch(snapshot.variables, {})
this.api.store.set({ ...result, fetching: false })
}
}

View File

@ -2,12 +2,15 @@
import { createHistoryResults } from '$testing/testdata'
import { Story } from '@storybook/addon-svelte-csf'
import HistoryPanel from './HistoryPanel.svelte'
import { readable } from 'svelte/store'
export const meta = {
component: HistoryPanel,
parameters: {
sveltekit_experimental: {
stores: {
page: {},
page: {
url: new URL(window.location.href),
},
},
},
},
@ -17,12 +20,19 @@
<script lang="ts">
let commitCount = 5
$: [initial] = createHistoryResults(1, commitCount)
$: store = {
...readable({ data: initial.nodes, fetching: false }),
fetchMore: () => {},
fetchWhile: () => Promise.resolve(),
capture: () => undefined,
restore: () => Promise.resolve(),
}
</script>
<Story name="Default">
<p>Commits to show: <input type="number" bind:value={commitCount} min="1" max="100" /></p>
<hr />
{#key commitCount}
<HistoryPanel history={initial} enableInlineDiffs={false} fetchMore={() => {}} />
<HistoryPanel history={store} enableInlineDiff={false} />
{/key}
</Story>

View File

@ -1,76 +1,64 @@
<script lang="ts" context="module">
type HistoryStore = InfinityQueryStore<HistoryPanel_HistoryConnection['nodes'], { afterCursor: string | null }>
export interface Capture {
history: ReturnType<HistoryStore['capture']>
scroller?: ScrollerCapture
}
</script>
<script lang="ts">
import { tick } from 'svelte'
import { page } from '$app/stores'
import Avatar from '$lib/Avatar.svelte'
import { SourcegraphURL } from '$lib/common'
import { scrollIntoViewOnMount } from '$lib/dom'
import type { InfinityQueryStore } from '$lib/graphql'
import Icon from '$lib/Icon.svelte'
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
import Scroller, { type Capture as ScrollerCapture } from '$lib/Scroller.svelte'
import { replaceRevisionInURL } from '$lib/shared'
import Timestamp from '$lib/Timestamp.svelte'
import Tooltip from '$lib/Tooltip.svelte'
import { Badge } from '$lib/wildcard'
import { Alert, Badge } from '$lib/wildcard'
import type { HistoryPanel_HistoryConnection } from './HistoryPanel.gql'
export let history: HistoryPanel_HistoryConnection | null
export let fetchMore: (afterCursor: string | null) => void
export let loading: boolean = false
export let history: HistoryStore
export let enableInlineDiff: boolean = false
export let enableViewAtCommit: boolean = false
export function capture(): Capture {
return {
history: history.capture(),
scroller: scroller?.capture(),
}
}
export async function restore(data: Capture) {
await history.restore(data.history)
// If the selected revision is not in the set of currently loaded commits, load more
if (selectedRev) {
await history.fetchWhile(data => !data.find(commit => selectedRev?.startsWith(commit.abbreviatedOID)))
}
if (data.scroller) {
// Wait until DOM was update before updating the scroll position
await tick()
// restore might be called when the history panel is closed
// in which case scroller doesn't exist
scroller?.restore(data.scroller)
}
}
function loadMore() {
if (history?.pageInfo.hasNextPage) {
fetchMore(history.pageInfo.endCursor)
}
}
let scroller: Scroller
// If the selected revision is not in the set of currently loaded commits, load more
$: if (
selectedRev &&
history &&
history.nodes.length > 0 &&
!history.nodes.some(commit => commit.abbreviatedOID === selectedRev) &&
history.pageInfo.hasNextPage
) {
loadMore()
}
$: selectedRev = $page.url?.searchParams.get('rev')
$: diffEnabled = $page.url?.searchParams.has('diff')
$: closeURL = SourcegraphURL.from($page.url).deleteSearchParameter('rev', 'diff').toString()
</script>
<Scroller bind:this={scroller} margin={200} on:more={loadMore}>
{#if history}
<Scroller bind:this={scroller} margin={200} on:more={history.fetchMore}>
{#if $history.data}
<table>
{#each history.nodes as commit (commit.id)}
{#each $history.data as commit (commit.id)}
{@const selected = commit.abbreviatedOID === selectedRev || commit.oid === selectedRev}
<tr class:selected use:scrollIntoViewOnMount={selected}>
<td>
@ -111,12 +99,22 @@
{/each}
</table>
{/if}
{#if !history || loading}
<LoadingSpinner />
{#if $history.fetching}
<div class="info">
<LoadingSpinner />
</div>
{:else if $history.error}
<div class="info">
<Alert variant="danger">Unable to load history: {$history.error.message}</Alert>
</div>
{/if}
</Scroller>
<style lang="scss">
.info {
padding: 0.5rem 1rem;
}
table {
width: 100%;
max-width: 100%;

View File

@ -1,4 +1,10 @@
import type { ResolvedRevision } from '../../routes/[...repo=reporev]/+layout'
import type { ResolvedRepository } from '../../routes/[...repo=reporev]/layout.gql'
export interface ResolvedRevision {
repo: ResolvedRepository
defaultBranch: string
commitID: string
}
export function getRevisionLabel(
urlRevision: string | undefined,

View File

@ -200,7 +200,8 @@
// When a toggle is unset, we revert back to the default pattern type. However, if the default pattern type
// is regexp, we should revert to keyword instead (otherwise it's not possible to disable the toggle).
function getUnselectedPatternType(): SearchPatternType {
const defaultPatternType = ($settings?.['search.defaultPatternType'] as SearchPatternType) ?? SearchPatternType.keyword
const defaultPatternType =
($settings?.['search.defaultPatternType'] as SearchPatternType) ?? SearchPatternType.keyword
return defaultPatternType === SearchPatternType.regexp ? SearchPatternType.keyword : defaultPatternType
}

View File

@ -63,7 +63,6 @@
import type { LayoutData, Snapshot } from './$types'
import FileTree from './FileTree.svelte'
import { createFileTreeStore } from './fileTreeStore'
import type { GitHistory_HistoryConnection, RepoPage_ReferencesLocationConnection } from './layout.gql'
import ReferencePanel from './ReferencePanel.svelte'
export let data: LayoutData
@ -91,28 +90,15 @@
let fileTreeSidePanel: Panel
let historyPanel: HistoryPanel
let selectedTab: number | null = null
let commitHistory: GitHistory_HistoryConnection | null
let references: RepoPage_ReferencesLocationConnection | null
const fileTreeStore = createFileTreeStore({ fetchFileTreeData: fetchSidebarFileTree })
$: ({ revision = '', parentPath, repoName, resolvedRevision, isCodyAvailable } = data)
$: fileTreeStore.set({ repoName, revision: resolvedRevision.commitID, path: parentPath })
$: commitHistoryQuery = data.commitHistory
$: if (!!commitHistoryQuery) {
// Reset commit history when the query observable changes. Without
// this we are showing the commit history of the previously selected
// file/folder until the new commit history is loaded.
commitHistory = null
}
$: commitHistory = $commitHistoryQuery?.data?.repository?.commit?.ancestors ?? null
// The observable query to fetch references (due to infinite scrolling)
$: sgURL = SourcegraphURL.from($page.url)
$: selectedLine = sgURL.lineRange
$: referenceQuery =
sgURL.viewState === 'references' && selectedLine?.line ? data.getReferenceStore(selectedLine) : null
$: references = $referenceQuery?.data?.repository?.commit?.blob?.lsif?.references ?? null
afterNavigate(async () => {
// We need to wait for referenceQuery to be updated before checking its state
@ -289,32 +275,22 @@
{#key data.filePath}
<HistoryPanel
bind:this={historyPanel}
history={commitHistory}
loading={$commitHistoryQuery?.fetching ?? true}
fetchMore={commitHistoryQuery.fetchMore}
history={data.commitHistory}
enableInlineDiff={$page.data.enableInlineDiff}
enableViewAtCommit={$page.data.enableViewAtCommit}
/>
{/key}
</TabPanel>
<TabPanel title="References" shortcut={referenceHotkey}>
{#if !referenceQuery}
{#if referenceQuery}
<ReferencePanel references={referenceQuery} />
{:else}
<div class="info">
<Alert variant="info"
>Hover over a symbol and click "Find references" to find references to the
symbol.</Alert
>
</div>
{:else if $referenceQuery && !$referenceQuery.fetching && (!references || references.nodes.length === 0)}
<div class="info">
<Alert variant="info">No references found.</Alert>
</div>
{:else}
<ReferencePanel
connection={references}
loading={$referenceQuery?.fetching ?? false}
on:more={referenceQuery?.fetchMore}
/>
{/if}
</TabPanel>
</Tabs>

View File

@ -1,11 +1,10 @@
import { dirname } from 'path'
import { from } from 'rxjs'
import { readable, derived, type Readable } from 'svelte/store'
import { CodyContextFiltersSchema, getFiltersFromCodyContextFilters } from '$lib/cody/config'
import type { LineOrPositionOrRange } from '$lib/common'
import { getGraphQLClient, infinityQuery, type GraphQLClient } from '$lib/graphql'
import { getGraphQLClient, infinityQuery, type GraphQLClient, IncrementalRestoreStrategy } from '$lib/graphql'
import { ROOT_PATH, fetchSidebarFileTree } from '$lib/repo/api/tree'
import { resolveRevision } from '$lib/repo/utils'
import { parseRepoRevision } from '$lib/shared'
@ -54,45 +53,30 @@ export const load: LayoutLoad = async ({ parent, params }) => {
commitHistory: infinityQuery({
client,
query: GitHistoryQuery,
variables: from(
resolvedRevision.then(revspec => ({
repoName,
revspec,
filePath,
first: HISTORY_COMMITS_PER_PAGE,
afterCursor: null as string | null,
}))
),
nextVariables: previousResult => {
if (previousResult?.data?.repository?.commit?.ancestors?.pageInfo?.hasNextPage) {
return {
afterCursor: previousResult.data.repository.commit.ancestors.pageInfo.endCursor,
}
}
return undefined
},
combine: (previousResult, nextResult) => {
if (!nextResult.data?.repository?.commit) {
return nextResult
}
const previousNodes = previousResult.data?.repository?.commit?.ancestors?.nodes ?? []
const nextNodes = nextResult.data.repository?.commit?.ancestors.nodes ?? []
variables: resolvedRevision.then(revspec => ({
repoName,
revspec,
filePath,
first: HISTORY_COMMITS_PER_PAGE,
afterCursor: null as string | null,
})),
map: result => {
const anestors = result.data?.repository?.commit?.ancestors
return {
...nextResult,
data: {
repository: {
...nextResult.data.repository,
commit: {
...nextResult.data.repository.commit,
ancestors: {
...nextResult.data.repository.commit.ancestors,
nodes: [...previousNodes, ...nextNodes],
},
},
},
},
nextVariables: anestors?.pageInfo.hasNextPage
? { afterCursor: anestors.pageInfo.endCursor }
: undefined,
data: anestors?.nodes,
error: result.error,
}
},
merge: (previous, next) => (previous ?? []).concat(next ?? []),
createRestoreStrategy: api =>
new IncrementalRestoreStrategy(
api,
n => n.length,
n => ({ first: n.length })
),
}),
// We are not extracting the selected position from the URL because that creates a dependency
@ -101,56 +85,27 @@ export const load: LayoutLoad = async ({ parent, params }) => {
infinityQuery({
client,
query: RepoPage_PreciseCodeIntel,
variables: from(
resolvedRevision.then(revspec => ({
repoName,
revspec,
filePath,
first: REFERENCES_PER_PAGE,
// Line and character are 1-indexed, but the API expects 0-indexed
line: lineOrPosition.line - 1,
character: lineOrPosition.character! - 1,
afterCursor: null as string | null,
}))
),
nextVariables: previousResult => {
if (previousResult?.data?.repository?.commit?.blob?.lsif?.references.pageInfo.hasNextPage) {
return {
afterCursor: previousResult.data.repository.commit.blob.lsif.references.pageInfo.endCursor,
}
}
return undefined
},
combine: (previousResult, nextResult) => {
if (!nextResult.data?.repository?.commit?.blob?.lsif) {
return nextResult
}
const previousNodes = previousResult.data?.repository?.commit?.blob?.lsif?.references?.nodes ?? []
const nextNodes = nextResult.data?.repository?.commit?.blob?.lsif?.references?.nodes ?? []
variables: resolvedRevision.then(revspec => ({
repoName,
revspec,
filePath,
first: REFERENCES_PER_PAGE,
// Line and character are 1-indexed, but the API expects 0-indexed
line: lineOrPosition.line - 1,
character: lineOrPosition.character! - 1,
afterCursor: null as string | null,
})),
map: result => {
const references = result.data?.repository?.commit?.blob?.lsif?.references
return {
...nextResult,
data: {
repository: {
...nextResult.data.repository,
commit: {
...nextResult.data.repository.commit,
blob: {
...nextResult.data.repository.commit.blob,
lsif: {
...nextResult.data.repository.commit.blob.lsif,
references: {
...nextResult.data.repository.commit.blob.lsif.references,
nodes: [...previousNodes, ...nextNodes],
},
},
},
},
},
},
nextVariables: references?.pageInfo.hasNextPage
? { afterCursor: references.pageInfo.endCursor }
: undefined,
data: references?.nodes,
error: result.error,
}
},
merge: (previous, next) => (previous ?? []).concat(next ?? []),
}),
}
}

View File

@ -1,8 +1,10 @@
<script lang="ts">
import { SourcegraphURL } from '$lib/common'
import type { InfinityQueryStore } from '$lib/graphql'
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
import Scroller from '$lib/Scroller.svelte'
import Tooltip from '$lib/Tooltip.svelte'
import { Alert } from '$lib/wildcard'
import Panel from '$lib/wildcard/resizable-panel/Panel.svelte'
import PanelGroup from '$lib/wildcard/resizable-panel/PanelGroup.svelte'
import PanelResizeHandle from '$lib/wildcard/resizable-panel/PanelResizeHandle.svelte'
@ -11,8 +13,7 @@
import type { ReferencePanel_LocationConnection, ReferencePanel_Location } from './ReferencePanel.gql'
import ReferencePanelCodeExcerpt from './ReferencePanelCodeExcerpt.svelte'
export let connection: ReferencePanel_LocationConnection | null
export let loading: boolean
export let references: InfinityQueryStore<ReferencePanel_LocationConnection['nodes']>
// It appears that the backend returns duplicate locations. We need to filter them out.
function unique(locations: ReferencePanel_Location[]): ReferencePanel_Location[] {
@ -41,13 +42,18 @@
let selectedLocation: ReferencePanel_Location | null = null
$: previewURL = selectedLocation ? getPreviewURL(selectedLocation) : null
$: locations = connection ? unique(connection.nodes) : []
$: locations = $references.data ? unique($references.data) : []
</script>
<div class="root">
<PanelGroup id="references">
<Panel id="references-list">
<Scroller margin={600} on:more>
<Scroller margin={600} on:more={references.fetchMore}>
{#if !$references.fetching && !$references.error && locations.length === 0}
<div class="info">
<Alert variant="info">No references found.</Alert>
</div>
{/if}
<ul>
{#each locations as location (location.canonicalURL)}
{@const selected = selectedLocation?.canonicalURL === location.canonicalURL}
@ -76,8 +82,12 @@
</li>
{/each}
</ul>
{#if loading}
{#if $references.fetching}
<div class="loader"><LoadingSpinner center /></div>
{:else if $references.error}
<div class="loader">
<Alert variant="danger">Unable to load references: {$references.error.message}</Alert>
</div>
{/if}
</Scroller>
</Panel>

View File

@ -378,7 +378,10 @@ test.describe('cody sidebar', () => {
})
})
test('disabled when disabled on instance', async ({ page, sg }) => {
test.fixme('disabled when disabled on instance', async ({ page, sg }) => {
// These tests seem to take longer than the default timeout
test.setTimeout(10000)
sg.setWindowContext({
codyEnabledOnInstance: false,
})
@ -387,7 +390,10 @@ test.describe('cody sidebar', () => {
await doesNotHaveCody(page)
})
test('disabled when disabled for user', async ({ page, sg }) => {
test.fixme('disabled when disabled for user', async ({ page, sg }) => {
// These tests seem to take longer than the default timeout
test.setTimeout(10000)
sg.setWindowContext({
codyEnabledOnInstance: true,
codyEnabledForCurrentUser: false,

View File

@ -1,8 +1,9 @@
import { error } from '@sveltejs/kit'
import { isErrorLike } from '$lib/common'
import { getGraphQLClient, mapOrThrow } from '$lib/graphql'
import { GitRefType } from '$lib/graphql-types'
import type { ResolvedRevision } from '$lib/repo/utils'
import { RevisionNotFoundError } from '$lib/shared'
import type { LayoutLoad } from './$types'
import { RepositoryGitCommits, RepositoryGitRefs } from './layout.gql'
@ -11,19 +12,27 @@ export const load: LayoutLoad = async ({ parent }) => {
// By validating the resolved revision here we can guarantee to
// subpages that if they load the requested revision exists. This
// relieves subpages from testing whether the revision is valid.
const { repoName, resolvedRevisionOrError } = await parent()
const { revision, defaultBranch, resolvedRepository, repoName } = await parent()
if (isErrorLike(resolvedRevisionOrError)) {
error(404, resolvedRevisionOrError)
const commit = resolvedRepository.commit || resolvedRepository.changelist?.commit
if (!commit) {
error(404, new RevisionNotFoundError(revision))
}
const client = getGraphQLClient()
return {
resolvedRevision: resolvedRevisionOrError,
resolvedRevision: {
repo: resolvedRepository,
commitID: commit.oid,
defaultBranch,
} satisfies ResolvedRevision,
// Repository pickers queries (branch, tags and commits)
getRepoBranches: (searchTerm: string) =>
getGraphQLClient()
client
.query(RepositoryGitRefs, {
repoName: repoName,
repoName,
query: searchTerm,
type: GitRefType.GIT_BRANCH,
})
@ -37,7 +46,7 @@ export const load: LayoutLoad = async ({ parent }) => {
})
),
getRepoTags: (searchTerm: string) =>
getGraphQLClient()
client
.query(RepositoryGitRefs, {
repoName,
query: searchTerm,
@ -53,11 +62,11 @@ export const load: LayoutLoad = async ({ parent }) => {
})
),
getRepoCommits: (searchTerm: string) =>
getGraphQLClient()
client
.query(RepositoryGitCommits, {
repoName,
query: searchTerm,
revision: resolvedRevisionOrError.commitID,
revision: commit.oid,
})
.then(
mapOrThrow(({ data }) => {

View File

@ -13,26 +13,25 @@
import RepositoryRevPicker from '../../../RepositoryRevPicker.svelte'
import type { PageData, Snapshot } from './$types'
import type { CommitsPage_GitCommitConnection } from './page.gql'
export let data: PageData
// This tracks the number of commits that have been loaded and the current scroll
// position, so both can be restored when the user refreshes the page or navigates
// back to it.
export const snapshot: Snapshot<{ commitCount: number; scroller: ScrollerCapture }> = {
export const snapshot: Snapshot<{
commits: ReturnType<typeof data.commitsQuery.capture>
scroller: ScrollerCapture
}> = {
capture() {
return {
commitCount: commits?.nodes.length ?? 0,
commits: commitsQuery.capture(),
scroller: scroller.capture(),
}
},
async restore(snapshot) {
if (snapshot?.commitCount !== undefined && get(navigating)?.type === 'popstate') {
await commitsQuery?.restore(result => {
const count = result.data?.repository?.commit?.ancestors.nodes?.length
return !!count && count < snapshot.commitCount
})
if (get(navigating)?.type === 'popstate') {
await commitsQuery?.restore(snapshot.commits)
}
scroller.restore(snapshot.scroller)
},
@ -43,14 +42,9 @@
}
let scroller: Scroller
let commits: CommitsPage_GitCommitConnection | null = null
$: commitsQuery = data.commitsQuery
// We conditionally check for the ancestors field to be able to show
// previously loaded commits when an error occurs while fetching more commits.
$: if ($commitsQuery?.data?.repository?.commit?.ancestors) {
commits = $commitsQuery.data.repository.commit.ancestors
}
$: commits = $commitsQuery.data
$: pageTitle = (() => {
const parts = ['Commits']
if (data.path) {
@ -86,9 +80,9 @@
</header>
<section>
<Scroller bind:this={scroller} margin={600} on:more={fetchMore}>
{#if !$commitsQuery.restoring && commits}
{#if commits}
<ul class="commits">
{#each commits.nodes as commit (commit.canonicalURL)}
{#each commits as commit (commit.canonicalURL)}
<li>
<div class="commit">
<Commit {commit} />
@ -122,7 +116,7 @@
{/each}
</ul>
{/if}
{#if $commitsQuery.fetching || $commitsQuery.restoring}
{#if $commitsQuery.fetching}
<div class="footer">
<LoadingSpinner />
</div>

View File

@ -1,6 +1,4 @@
import { from } from 'rxjs'
import { getGraphQLClient, infinityQuery } from '$lib/graphql'
import { IncrementalRestoreStrategy, getGraphQLClient, infinityQuery } from '$lib/graphql'
import { resolveRevision } from '$lib/repo/utils'
import { parseRepoRevision } from '$lib/shared'
@ -18,45 +16,31 @@ export const load: PageLoad = ({ parent, params }) => {
const commitsQuery = infinityQuery({
client,
query: CommitsPage_CommitsQuery,
variables: from(
resolvedRevision.then(revision => ({
repoName,
revision,
first: PAGE_SIZE,
path,
afterCursor: null as string | null,
}))
),
nextVariables: previousResult => {
if (previousResult?.data?.repository?.commit?.ancestors?.pageInfo?.hasNextPage) {
return {
afterCursor: previousResult.data.repository.commit.ancestors.pageInfo.endCursor,
}
}
return undefined
},
combine: (previousResult, nextResult) => {
if (!nextResult.data?.repository?.commit) {
return nextResult
}
const previousNodes = previousResult.data?.repository?.commit?.ancestors?.nodes ?? []
const nextNodes = nextResult.data.repository?.commit?.ancestors.nodes ?? []
variables: resolvedRevision.then(revision => ({
repoName,
revision,
first: PAGE_SIZE,
path,
afterCursor: null as string | null,
})),
map: result => {
const ancestors = result.data?.repository?.commit?.ancestors
return {
...nextResult,
data: {
repository: {
...nextResult.data.repository,
commit: {
...nextResult.data.repository.commit,
ancestors: {
...nextResult.data.repository.commit.ancestors,
nodes: [...previousNodes, ...nextNodes],
},
},
},
},
nextVariables:
ancestors?.pageInfo?.endCursor && ancestors.pageInfo.hasNextPage
? { afterCursor: ancestors.pageInfo.endCursor }
: undefined,
data: ancestors?.nodes,
error: result.error,
}
},
merge: (previous, next) => (previous ?? []).concat(next ?? []),
createRestoreStrategy: api =>
new IncrementalRestoreStrategy(
api,
n => n.length,
n => ({ first: n.length })
),
})
return {

View File

@ -32,6 +32,7 @@
import { goto } from '$app/navigation'
import Icon from '$lib/Icon.svelte'
import Popover from '$lib/Popover.svelte'
import type { ResolvedRevision } from '$lib/repo/utils'
import { replaceRevisionInURL } from '$lib/shared'
import TabPanel from '$lib/TabPanel.svelte'
import Tabs from '$lib/Tabs.svelte'
@ -41,8 +42,6 @@
import ButtonGroup from '$lib/wildcard/ButtonGroup.svelte'
import CopyButton from '$lib/wildcard/CopyButton.svelte'
import type { ResolvedRevision } from '../+layout'
import Picker from './Picker.svelte'
import RepositoryRevPickerItem from './RepositoryRevPickerItem.svelte'

View File

@ -158,8 +158,8 @@
repoName={data.repoName}
displayRepoName={data.displayRepoName}
repoURL={data.repoURL}
externalURL={data.resolvedRevision?.repo?.externalURLs?.[0].url}
externalServiceKind={data.resolvedRevision?.repo?.externalURLs?.[0].serviceKind ?? undefined}
externalURL={data.resolvedRepository.externalURLs[0]?.url}
externalServiceKind={data.resolvedRepository.externalURLs[0]?.serviceKind ?? undefined}
/>
<TabsHeader id="repoheader" {tabs} selected={selectedTab} />

View File

@ -1,27 +1,12 @@
import { redirect, error } from '@sveltejs/kit'
import { error, redirect } from '@sveltejs/kit'
import { asError, loadMarkdownSyntaxHighlighting, type ErrorLike } from '$lib/common'
import { loadMarkdownSyntaxHighlighting } from '$lib/common'
import { getGraphQLClient, type GraphQLClient } from '$lib/graphql'
import {
CloneInProgressError,
RepoNotFoundError,
RepoSeeOtherError,
RevisionNotFoundError,
displayRepoName,
isRepoSeeOtherErrorLike,
isRevisionNotFoundErrorLike,
parseRepoRevision,
} from '$lib/shared'
import { CloneInProgressError, RepoNotFoundError, displayRepoName, parseRepoRevision } from '$lib/shared'
import type { LayoutLoad } from './$types'
import { ResolveRepoRevision, ResolvedRepository, type ResolveRepoRevisionResult } from './layout.gql'
export interface ResolvedRevision {
repo: ResolvedRepository & NonNullable<{ commit: ResolvedRepository['commit'] }>
commitID: string
defaultBranch: string
}
export const load: LayoutLoad = async ({ params, url, depends }) => {
const client = getGraphQLClient()
@ -35,29 +20,12 @@ export const load: LayoutLoad = async ({ params, url, depends }) => {
// An empty revision means we are at the default branch
const { repoName, revision = '' } = parseRepoRevision(params.repo)
let resolvedRevisionOrError: ResolvedRevision | ErrorLike
let resolvedRevision: ResolvedRevision | undefined
try {
resolvedRevisionOrError = await resolveRepoRevision({ client, repoName, revspec: revision })
resolvedRevision = resolvedRevisionOrError
} catch (repoError: unknown) {
const redirect = isRepoSeeOtherErrorLike(repoError)
if (redirect) {
redirectToExternalHost(redirect, url)
}
// TODO: use different error codes for different types of errors
// Let revision errors be handled by the nested layout so that we can
// still render the main repo navigation and header
if (!isRevisionNotFoundErrorLike(repoError)) {
error(400, asError(repoError))
}
resolvedRevisionOrError = asError(repoError)
}
const resolvedRepository = await resolveRepoRevision({
client,
repoName,
revspec: revision,
url,
})
return {
repoURL: '/' + params.repo,
@ -74,9 +42,9 @@ export const load: LayoutLoad = async ({ params, url, depends }) => {
* - an abbreviated commit SHA
* - a symbolic revision (e.g. a branch or tag name)
*/
displayRevision: displayRevision(revision, resolvedRevision),
resolvedRevisionOrError,
resolvedRevision,
displayRevision: displayRevision(revision, resolvedRepository),
defaultBranch: resolvedRepository.defaultBranch?.abbrevName || 'HEAD',
resolvedRepository: resolvedRepository,
}
}
@ -88,27 +56,18 @@ export const load: LayoutLoad = async ({ params, url, depends }) => {
* @param resolvedRevision The resolved revision
* @returns A human readable revision string
*/
function displayRevision(revision: string, resolvedRevision: ResolvedRevision | undefined): string {
function displayRevision(revision: string, resolvedRevision: ResolvedRepository | undefined): string {
if (!resolvedRevision) {
return revision
}
if (revision && resolvedRevision.commitID.startsWith(revision)) {
return resolvedRevision.commitID.slice(0, 7)
if (revision && resolvedRevision.commit?.oid.startsWith(revision)) {
return resolvedRevision.commit.oid?.slice(0, 7)
}
return revision
}
function redirectToExternalHost(externalRedirectURL: string, currentURL: URL): never {
const externalHostURL = new URL(externalRedirectURL)
const redirectURL = new URL(currentURL)
// Preserve the path of the current URL and redirect to the repo on the external host.
redirectURL.host = externalHostURL.host
redirectURL.protocol = externalHostURL.protocol
redirect(303, redirectURL.toString())
}
// This is a cache for resolved repository information to help in the following case:
// - The user navigates to a repository page with a symbolic revspec (e.g. a branch or tag name)
// - The user navigates to a permalink (i.e. URL with commit ID) for that very same revision
@ -120,15 +79,28 @@ function redirectToExternalHost(externalRedirectURL: string, currentURL: URL): n
// have previously seen in a response.
const resolvedRepoRevision = new Map<string, ResolveRepoRevisionResult>()
/**
* This function takes the repository name and revision from the URL and fetches the corresponding
* repository information.
* One of three things can happen:
* - If the repository has a server side redirect configured, the user is redirected to the new URL
* - If the repository was not found, is currently being cloned or is scheduled for cloning, an error is thrown
* - Otherwise the resolved repository information is returned
*
* Note that it's possible that the provided revision does not exist in the repository. In that case
* the repository information is still returned, but the commit information will be missing.
*/
async function resolveRepoRevision({
client,
repoName,
revspec = '',
url,
}: {
client: GraphQLClient
repoName: string
revspec?: string
}): Promise<ResolvedRevision> {
url: URL
}): Promise<ResolvedRepository> {
const cacheKey = `${repoName}@${revspec}`
let data: ResolveRepoRevisionResult | undefined
@ -153,42 +125,33 @@ async function resolveRepoRevision({
}
if (!data?.repositoryRedirect) {
throw new RepoNotFoundError(repoName)
error(404, new RepoNotFoundError(repoName))
}
if (data.repositoryRedirect.__typename === 'Redirect') {
throw new RepoSeeOtherError(data.repositoryRedirect.url)
const redirectURL = new URL(url)
const externalURL = new URL(data.repositoryRedirect.url)
// Preserve the path of the current URL and redirect to the repo on the external host.
redirectURL.host = externalURL.host
redirectURL.protocol = externalURL.protocol
redirect(303, redirectURL)
}
if (data.repositoryRedirect.mirrorInfo.cloneInProgress) {
throw new CloneInProgressError(repoName, data.repositoryRedirect.mirrorInfo.cloneProgress || undefined)
error(503, new CloneInProgressError(repoName, data.repositoryRedirect.mirrorInfo.cloneProgress || undefined))
}
if (!data.repositoryRedirect.mirrorInfo.cloned) {
throw new CloneInProgressError(repoName, 'queued for cloning')
error(503, new CloneInProgressError(repoName, 'queued for cloning'))
}
// The "revision" we queried for could be a commit or a changelist.
const commit = data.repositoryRedirect.commit || data.repositoryRedirect.changelist?.commit
if (!commit) {
throw new RevisionNotFoundError(revspec)
}
const defaultBranch = data.repositoryRedirect.defaultBranch?.abbrevName || 'HEAD'
/*
* TODO: What exactly is this check for?
if (!commit.tree) {
throw new RevisionNotFoundError(defaultBranch)
}
*/
// Cache the resolved repository information
resolvedRepoRevision.set(`${repoName}@${commit.oid}`, data)
return {
repo: data.repositoryRedirect,
commitID: commit.oid,
defaultBranch,
if (commit) {
resolvedRepoRevision.set(`${repoName}@${commit.oid}`, data)
}
return data.repositoryRedirect
}
/**

View File

@ -8,39 +8,34 @@
import GitReference from '$lib/repo/GitReference.svelte'
import Scroller, { type Capture as ScrollerCapture } from '$lib/Scroller.svelte'
import { Alert, Button, Input } from '$lib/wildcard'
import type { GitBranchesConnection } from '$testing/graphql-type-mocks'
import type { PageData, Snapshot } from './$types'
export let data: PageData
export const snapshot: Snapshot<{ count: number; scroller: ScrollerCapture }> = {
export const snapshot: Snapshot<{
branches: ReturnType<typeof data.branchesQuery.capture>
scroller: ScrollerCapture
}> = {
capture() {
return {
count: branchesConnection?.nodes.length ?? 0,
branches: data.branchesQuery.capture(),
scroller: scroller.capture(),
}
},
async restore(snapshot) {
if (snapshot?.count && get(navigating)?.type === 'popstate') {
await branchesQuery?.restore(result => {
const count = result.data?.repository?.branches?.nodes?.length
return !!count && count < snapshot.count
})
if (get(navigating)?.type === 'popstate') {
await data.branchesQuery?.restore(snapshot.branches)
}
scroller.restore(snapshot.scroller)
},
}
let scroller: Scroller
let branchesConnection: GitBranchesConnection | undefined
$: query = data.query
$: branchesQuery = data.branchesQuery
$: branchesConnection = $branchesQuery.data?.repository?.branches ?? branchesConnection
$: if (branchesQuery) {
branchesConnection = undefined
}
$: branches = $branchesQuery.data
</script>
<svelte:head>
@ -53,15 +48,15 @@
<Button variant="primary" type="submit">Search</Button>
</form>
<Scroller bind:this={scroller} margin={600} on:more={branchesQuery.fetchMore}>
{#if !$branchesQuery.restoring && branchesConnection}
{#if branches}
<table>
<tbody>
{#each branchesConnection.nodes as tag (tag)}
<GitReference ref={tag} />
{#each branches.nodes as branch (branch)}
<GitReference ref={branch} />
{:else}
<tr>
<td colspan="2">
<Alert variant="info">No tags found</Alert>
<Alert variant="info">No branches found</Alert>
</td>
</tr>
{/each}
@ -69,7 +64,7 @@
</table>
{/if}
<div>
{#if $branchesQuery.fetching || $branchesQuery.restoring}
{#if $branchesQuery.fetching}
<LoadingSpinner />
{:else if $branchesQuery.error}
<Alert variant="danger">
@ -78,12 +73,12 @@
{/if}
</div>
</Scroller>
{#if branchesConnection && branchesConnection.nodes.length > 0}
{#if branches && branches.nodes.length > 0}
<div class="footer">
{branchesConnection.totalCount}
{pluralize('branch', branchesConnection.totalCount)} total
{#if branchesConnection.totalCount > branchesConnection.nodes.length}
(showing {branchesConnection.nodes.length})
{branches.totalCount}
{pluralize('branch', branches.totalCount)} total
{#if branches.totalCount > branches.nodes.length}
(showing {branches.nodes.length})
{/if}
</div>
{/if}

View File

@ -1,4 +1,4 @@
import { getGraphQLClient, infinityQuery } from '$lib/graphql'
import { getGraphQLClient, infinityQuery, OverwriteRestoreStrategy } from '$lib/graphql'
import { parseRepoRevision } from '$lib/shared'
import type { PageLoad } from './$types'
@ -22,17 +22,22 @@ export const load: PageLoad = ({ params, url }) => {
withBehindAhead: true,
query,
},
nextVariables: previousResult => {
if (previousResult?.data?.repository?.branches?.pageInfo?.hasNextPage) {
return {
first: previousResult.data.repository.branches.nodes.length + PAGE_SIZE,
}
map: result => {
const branches = result.data?.repository?.branches
return {
nextVariables: branches?.pageInfo.hasNextPage
? { first: branches.nodes.length + PAGE_SIZE }
: undefined,
data: branches
? {
nodes: branches.nodes,
totalCount: branches.totalCount,
}
: undefined,
error: result.error,
}
return undefined
},
combine: (_previousResult, nextResult) => {
return nextResult
},
createRestoreStrategy: api => new OverwriteRestoreStrategy(api, data => ({ first: data.nodes.length })),
}),
}
}

View File

@ -21,7 +21,7 @@
interface Capture {
scroll: ScrollerCapture
diffCount: number
diffs?: ReturnType<NonNullable<typeof data.diff>['capture']>
expandedDiffs: Array<[number, boolean]>
}
@ -30,16 +30,13 @@
export const snapshot: Snapshot<Capture> = {
capture: () => ({
scroll: scroller.capture(),
diffCount: diffs?.nodes.length ?? 0,
diffs: diffQuery?.capture(),
expandedDiffs: Array.from(expandedDiffs.entries()),
}),
restore: async capture => {
expandedDiffs = new Map(capture.expandedDiffs)
if (capture?.diffCount !== undefined && get(navigating)?.type === 'popstate') {
await data.diff?.restore(result => {
const count = result.data?.repository?.comparison.fileDiffs.nodes.length
return !!count && count < capture.diffCount
})
if (get(navigating)?.type === 'popstate') {
await data.diff?.restore(capture.diffs)
}
scroller.restore(capture.scroll)
},
@ -50,7 +47,6 @@
let expandedDiffs = new Map<number, boolean>()
$: diffQuery = data.diff
$: diffs = $diffQuery?.data?.repository?.comparison.fileDiffs ?? null
afterNavigate(() => {
repositoryContext.set({ revision: data.commit.abbreviatedOID })
@ -66,7 +62,7 @@
<section>
{#if data.commit}
<Scroller bind:this={scroller} margin={600} on:more={data.diff?.fetchMore}>
<Scroller bind:this={scroller} margin={600} on:more={diffQuery?.fetchMore}>
<div class="header">
<div class="info"><Commit commit={data.commit} alwaysExpanded /></div>
<div class="parents">
@ -104,9 +100,9 @@
</div>
</div>
<hr />
{#if !$diffQuery?.restoring && diffs}
{#if $diffQuery?.data}
<ul class="diffs">
{#each diffs.nodes as node, index}
{#each $diffQuery.data as node, index}
<li>
<FileDiff
fileDiff={node}
@ -117,7 +113,7 @@
{/each}
</ul>
{/if}
{#if $diffQuery?.fetching || $diffQuery?.restoring}
{#if $diffQuery?.fetching}
<LoadingSpinner />
{:else if $diffQuery?.error}
<div class="error">

View File

@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit'
import { getGraphQLClient, infinityQuery } from '$lib/graphql'
import { IncrementalRestoreStrategy, getGraphQLClient, infinityQuery } from '$lib/graphql'
import { parseRepoRevision } from '$lib/shared'
import type { PageLoad } from './$types'
@ -38,44 +38,21 @@ export const load: PageLoad = async ({ params }) => {
first: PAGE_SIZE,
after: null as string | null,
},
nextVariables: previousResult => {
if (
!previousResult.error &&
previousResult?.data?.repository?.comparison?.fileDiffs?.pageInfo?.hasNextPage
) {
return {
after: previousResult.data.repository.comparison.fileDiffs.pageInfo.endCursor,
}
}
return undefined
},
combine: (previousResult, nextResult) => {
if (!nextResult.data?.repository?.comparison) {
return {
...nextResult,
// When this code path is executed we probably have an error.
// We still want to show the data that was loaded before the error occurred.
data: previousResult.data,
}
}
const previousNodes = previousResult.data?.repository?.comparison?.fileDiffs?.nodes ?? []
const nextNodes = nextResult.data.repository?.comparison?.fileDiffs?.nodes ?? []
map: result => {
const diffs = result.data?.repository?.comparison.fileDiffs
return {
...nextResult,
data: {
repository: {
...nextResult.data.repository,
comparison: {
...nextResult.data.repository.comparison,
fileDiffs: {
...nextResult.data.repository.comparison.fileDiffs,
nodes: [...previousNodes, ...nextNodes],
},
},
},
},
nextVariables: diffs?.pageInfo.hasNextPage ? { after: diffs?.pageInfo.endCursor } : undefined,
data: diffs?.nodes,
error: result.error,
}
},
merge: (previous, next) => (previous ?? []).concat(next ?? []),
createRestoreStrategy: api =>
new IncrementalRestoreStrategy(
api,
n => n.length,
n => ({ first: n.length })
),
})
: null

View File

@ -10,37 +10,29 @@
import { Alert, Button, Input } from '$lib/wildcard'
import type { PageData, Snapshot } from './$types'
import type { GitTagsConnection } from './page.gql'
export let data: PageData
export const snapshot: Snapshot<{ count: number; scroller: ScrollerCapture }> = {
export const snapshot: Snapshot<{ tags: ReturnType<typeof data.tagsQuery.capture>; scroller: ScrollerCapture }> = {
capture() {
return {
count: tagsConnection?.nodes.length ?? 0,
tags: data.tagsQuery.capture(),
scroller: scroller.capture(),
}
},
async restore(snapshot) {
if (snapshot?.count && get(navigating)?.type === 'popstate') {
await tagsQuery?.restore(result => {
const count = result.data?.repository?.gitRefs?.nodes?.length
return !!count && count < snapshot.count
})
if (snapshot?.tags && get(navigating)?.type === 'popstate') {
await data.tagsQuery?.restore(snapshot.tags)
}
scroller.restore(snapshot.scroller)
},
}
let scroller: Scroller
let tagsConnection: GitTagsConnection | undefined
$: query = data.query
$: tagsQuery = data.tagsQuery
$: tagsConnection = $tagsQuery.data?.repository?.gitRefs ?? tagsConnection
$: if (tagsQuery) {
tagsConnection = undefined
}
$: tags = $tagsQuery.data
</script>
<svelte:head>
@ -53,10 +45,10 @@
<Button variant="primary" type="submit">Search</Button>
</form>
<Scroller bind:this={scroller} margin={600} on:more={tagsQuery.fetchMore}>
{#if !$tagsQuery.restoring && tagsConnection}
{#if tags}
<table>
<tbody>
{#each tagsConnection.nodes as tag (tag)}
{#each tags.nodes as tag (tag)}
<GitReference ref={tag} />
{:else}
<tr>
@ -69,7 +61,7 @@
</table>
{/if}
<div>
{#if $tagsQuery.fetching || $tagsQuery.restoring}
{#if $tagsQuery.fetching}
<LoadingSpinner />
{:else if $tagsQuery.error}
<Alert variant="danger">
@ -78,12 +70,12 @@
{/if}
</div>
</Scroller>
{#if tagsConnection && tagsConnection.nodes.length > 0}
{#if tags && tags.nodes.length > 0}
<div class="footer">
{tagsConnection.totalCount}
{pluralize('tag', tagsConnection.totalCount)} total
{#if tagsConnection.totalCount > tagsConnection.nodes.length}
(showing {tagsConnection.nodes.length})
{tags.totalCount}
{pluralize('tag', tags.totalCount)} total
{#if tags.totalCount > tags.nodes.length}
(showing {tags.nodes.length})
{/if}
</div>
{/if}

View File

@ -1,4 +1,4 @@
import { getGraphQLClient, infinityQuery } from '$lib/graphql'
import { OverwriteRestoreStrategy, getGraphQLClient, infinityQuery } from '$lib/graphql'
import { parseRepoRevision } from '$lib/shared'
import type { PageLoad } from './$types'
@ -22,17 +22,22 @@ export const load: PageLoad = ({ params, url }) => {
withBehindAhead: false,
query,
},
nextVariables: previousResult => {
if (previousResult?.data?.repository?.gitRefs?.pageInfo?.hasNextPage) {
return {
first: previousResult.data.repository.gitRefs.nodes.length + PAGE_SIZE,
}
map: result => {
const gitRefs = result.data?.repository?.gitRefs
return {
nextVariables: gitRefs?.pageInfo.hasNextPage
? { first: gitRefs.nodes.length + PAGE_SIZE }
: undefined,
data: gitRefs
? {
nodes: gitRefs.nodes,
totalCount: gitRefs.totalCount,
}
: undefined,
error: result.error,
}
return undefined
},
combine: (_previousResult, nextResult) => {
return nextResult
},
createRestoreStrategy: api => new OverwriteRestoreStrategy(api, data => ({ first: data.nodes.length })),
}),
}
}

View File

@ -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",
@ -1124,7 +1124,9 @@ ts_project(
"src/namespaces/index.ts",
"src/namespaces/navitems.ts",
"src/namespaces/routes.tsx",
"src/namespaces/telemetry.ts",
"src/namespaces/useAffiliatedNamespaces.ts",
"src/namespaces/useCanonicalPathForNamespaceResource.ts",
"src/nav/GlobalNavbar.tsx",
"src/nav/NavBar/NavBar.tsx",
"src/nav/NavBar/NavDropdown.tsx",
@ -1381,11 +1383,19 @@ ts_project(
"src/repo/utils.tsx",
"src/routes.constants.ts",
"src/routes.tsx",
"src/savedSearches/SavedSearchCreateForm.tsx",
"src/savedSearches/SavedSearchForm.tsx",
"src/savedSearches/SavedSearchListPage.tsx",
"src/savedSearches/Area.tsx",
"src/savedSearches/DetailPage.tsx",
"src/savedSearches/EditPage.tsx",
"src/savedSearches/Form.tsx",
"src/savedSearches/ListPage.tsx",
"src/savedSearches/NewForm.tsx",
"src/savedSearches/Page.tsx",
"src/savedSearches/SavedSearchIcon.tsx",
"src/savedSearches/SavedSearchModal.tsx",
"src/savedSearches/SavedSearchUpdateForm.tsx",
"src/savedSearches/graphql.ts",
"src/savedSearches/telemetry.ts",
"src/search-jobs/utility.ts",
"src/search/QuickLinks.tsx",
"src/search/SearchPageWrapper.tsx",
"src/search/autocompletion/hooks.ts",
@ -1894,6 +1904,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",
@ -1986,7 +1997,8 @@ ts_project(
"src/repo/releases/RepositoryReleasesTagsPage.test.tsx",
"src/repo/tree/TreePage.test.tsx",
"src/repo/utils.test.ts",
"src/savedSearches/SavedSearchForm.test.tsx",
"src/savedSearches/Form.test.tsx",
"src/savedSearches/graphql.mocks.ts",
"src/search/helpers.test.tsx",
"src/search/index.test.ts",
"src/search/input/recentSearches.test.ts",
@ -2391,7 +2403,10 @@ ts_project(
"src/repo/commits/GitCommitNode.story.tsx",
"src/repo/commits/RepositoryCommitsPage.story.tsx",
"src/repo/settings/RepoSettingsMirrorPage.story.tsx",
"src/savedSearches/SavedSearchForm.story.tsx",
"src/savedSearches/EditPage.story.tsx",
"src/savedSearches/Form.story.tsx",
"src/savedSearches/ListPage.story.tsx",
"src/savedSearches/NewForm.story.tsx",
"src/search/results/StreamingSearchResults.story.tsx",
"src/search/results/components/aggregation/SearchAggregationResult.story.tsx",
"src/search/results/sidebar/Revisions.story.tsx",

View File

@ -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
},
}

View File

@ -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

View File

@ -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}

View File

@ -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.
*/

View File

@ -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

View File

@ -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)
})
})

View File

@ -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

View File

@ -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]
}

View File

@ -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')}`)
})
})

View File

@ -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]
}

View File

@ -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')
})
})
})

View File

@ -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

View File

@ -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])
}

View File

@ -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'

View File

@ -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">

View 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('')
})
})

View File

@ -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 }
}

View File

@ -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"

View File

@ -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

View File

@ -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 ?? [],

View File

@ -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 ?? [],

View File

@ -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"

View File

@ -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"

View File

@ -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 => {

View File

@ -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 => {

View File

@ -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 => {

View File

@ -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 => {

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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 => {

View File

@ -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,

View File

@ -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"

View File

@ -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 => {

View File

@ -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'

View File

@ -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"

View File

@ -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"

View File

@ -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',

View File

@ -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 => {

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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>(

View File

@ -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"

View File

@ -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"

View File

@ -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 => {

View File

@ -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) {

View File

@ -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.

View File

@ -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,

View File

@ -603,7 +603,7 @@ describe('Search', () => {
})
describe('Saved searches', () => {
test('is styled correctly, with saved searches', async () => {
test('list page', async () => {
testContext.overrideGraphQL({
...commonSearchGraphQLResults,
SavedSearches: () => ({
@ -613,31 +613,76 @@ describe('Search', () => {
__typename: 'SavedSearch',
description: 'Demo',
id: 'U2F2ZWRTZWFyY2g6NQ==',
namespace: { __typename: 'User', id: 'user123', namespaceName: 'test' },
notify: false,
notifySlack: false,
owner: { __typename: 'User', id: 'user123', namespaceName: 'test' },
createdAt: '2020-04-21T10:10:10Z',
updatedAt: '2020-04-21T10:10:10Z',
query: 'context:global Batch Change patternType:literal',
slackWebhookURL: null,
url: '/saved-searches/U2F2ZWRTZWFyY2g6NQ==',
viewerCanAdminister: true,
},
],
totalCount: 1,
pageInfo: {
startCursor: 'U2F2ZWRTZWFyY2g6NQ==',
endCursor: 'U2F2ZWRTZWFyY2g6NQ==',
startCursor:
'U2F2ZWRTZWFyY2hDdXJzb3I6W3siYyI6InVwZGF0ZWRfYXQiLCJ2IjoiMTU4NzQ2MzgxMDAwMDAwMDAwMCIsImQiOiIifV0=',
endCursor:
'U2F2ZWRTZWFyY2hDdXJzb3I6W3siYyI6InVwZGF0ZWRfYXQiLCJ2IjoiMTU4NzQ2MzgxMDAwMDAwMDAwMCIsImQiOiIifV0=',
hasNextPage: false,
hasPreviousPage: false,
},
},
}),
ViewerAffiliatedNamespaces: () => ({
viewer: {
affiliatedNamespaces: {
nodes: [
{
__typename: 'User',
id: 'user123',
namespaceName: 'test',
},
{
__typename: 'Org',
id: 'org456',
namespaceName: 'test-org',
displayName: 'Test Org',
},
],
},
},
}),
})
await driver.page.goto(driver.sourcegraphBaseUrl + '/users/test/searches')
await driver.page.goto(driver.sourcegraphBaseUrl + '/saved-searches')
await driver.page.waitForSelector('[data-testid="saved-searches-list-page"]')
await accessibilityAudit(driver.page)
})
test('is styled correctly, with saved search form', async () => {
await driver.page.goto(driver.sourcegraphBaseUrl + '/users/test/searches/add')
test('new form', async () => {
testContext.overrideGraphQL({
...commonSearchGraphQLResults,
ViewerAffiliatedNamespaces: () => ({
viewer: {
affiliatedNamespaces: {
nodes: [
{
__typename: 'User',
id: 'user123',
namespaceName: 'test',
},
{
__typename: 'Org',
id: 'org456',
namespaceName: 'test-org',
displayName: 'Test Org',
},
],
},
},
}),
})
await driver.page.goto(driver.sourcegraphBaseUrl + '/saved-searches/new')
await driver.page.waitForSelector('[data-testid="saved-search-form"]')
await accessibilityAudit(driver.page)
})

View File

@ -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>(

View File

@ -15,7 +15,7 @@ export const NamespaceSelector: React.FunctionComponent<{
/** Selected namespace ID. */
value?: string
onSelect: (namespace: Namespace['id']) => void
onSelect?: (namespace: Namespace['id']) => void
label?: string
description?: ReactNode
@ -40,7 +40,7 @@ export const NamespaceSelector: React.FunctionComponent<{
const selectedNamespace =
namespaces?.find(namespace => namespace.id === event.target.value) || namespaces?.at(0)
if (selectedNamespace) {
parentOnSelect(selectedNamespace.id)
parentOnSelect?.(selectedNamespace.id)
}
},
[disabled, parentOnSelect, namespaces]

View File

@ -1,36 +1,28 @@
import { lazyComponent } from '@sourcegraph/shared/src/util/lazyComponent'
import { useEffect, type FunctionComponent } from 'react'
import { useNavigate } from 'react-router-dom'
import { urlToSavedSearchesList } from '../savedSearches/ListPage'
import type { NamespaceProps } from '.'
import type { NamespaceAreaRoute } from './NamespaceArea'
const SavedSearchListPage = lazyComponent(() => import('../savedSearches/SavedSearchListPage'), 'SavedSearchListPage')
const SavedSearchCreateForm = lazyComponent(
() => import('../savedSearches/SavedSearchCreateForm'),
'SavedSearchCreateForm'
)
const SavedSearchUpdateForm = lazyComponent(
() => import('../savedSearches/SavedSearchUpdateForm'),
'SavedSearchUpdateForm'
)
export const namespaceAreaRoutes: readonly NamespaceAreaRoute[] = [
{
path: 'searches',
render: props => <SavedSearchListPage {...props} telemetryRecorder={props.platformContext.telemetryRecorder} />,
condition: () => window.context?.codeSearchEnabledOnInstance,
},
{
path: 'searches/add',
render: props => (
<SavedSearchCreateForm {...props} telemetryRecorder={props.platformContext.telemetryRecorder} />
),
condition: () => window.context?.codeSearchEnabledOnInstance,
},
{
path: 'searches/:id',
render: props => (
<SavedSearchUpdateForm {...props} telemetryRecorder={props.platformContext.telemetryRecorder} />
),
path: 'searches/*',
render: props => <SavedSearchesRedirect {...props} />,
condition: () => window.context?.codeSearchEnabledOnInstance,
},
]
/**
* Redirect from `/users/USER/searches` and `/orgs/ORG/searches` to the new global URL path
* `/searches?owner=OWNER`, for backcompat.
*/
const SavedSearchesRedirect: FunctionComponent<NamespaceProps> = ({ namespace }) => {
const navigate = useNavigate()
useEffect(() => {
navigate(urlToSavedSearchesList(namespace.id))
}, [navigate, namespace.id])
return null
}

View File

@ -0,0 +1,9 @@
import { NamespaceProps } from '.'
export function namespaceTelemetryMetadata(
namespace: Pick<NamespaceProps['namespace'], '__typename'>
): Record<string, number> {
return {
[`namespaceType${namespace.__typename}`]: 1,
}
}

View File

@ -0,0 +1,27 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import type { NamespaceAreaContext } from './NamespaceArea'
type Namespace = Pick<NamespaceAreaContext['namespace'], 'id'>
/**
* If we're viewing a namespaced resource (such as a saved search) at a URL like
* `/users/myuser/RESOURCES/ID`, ensure that the resource is actually owned by the given namespace
* (i.e., `RESOURCES/ID` is owned by `myuser`). If not, redirect to the resource at the canonical
* URL with the correct owner namespace. The purpose of this is to avoid allowing the use of URLs
* that mislead the user about who owns the resource.
*/
export function useCanonicalPathForNamespaceResource(
urlNamespace: Namespace,
resource: { owner: Namespace; url: string } | undefined | null,
urlPathSuffix?: string
): void {
const navigate = useNavigate()
useEffect(() => {
if (resource && urlNamespace.id !== resource.owner.id) {
navigate(`${resource.url}${urlPathSuffix ?? ''}`, { replace: true })
}
}, [urlNamespace.id, navigate, resource, urlPathSuffix])
}

View File

@ -306,6 +306,12 @@ export const InlineNavigationPanel: FC<InlineNavigationPanelProps> = props => {
const toolsItems = useMemo(() => {
const items: (NavDropdownItem | false)[] = [
props.authenticatedUser
? {
path: PageRoutes.SavedSearches,
content: 'Saved Searches',
}
: false,
showSearchContext && { path: PageRoutes.Contexts, content: 'Contexts' },
showSearchNotebook && { path: PageRoutes.Notebooks, content: 'Notebooks' },
// We hardcode the code monitoring path here because PageRoutes.CodeMonitoring is a catch-all
@ -317,7 +323,7 @@ export const InlineNavigationPanel: FC<InlineNavigationPanelProps> = props => {
},
]
return items.filter<NavDropdownItem>((item): item is NavDropdownItem => !!item)
}, [showSearchContext, showSearchJobs, showCodeMonitoring, showSearchNotebook])
}, [showSearchContext, showSearchJobs, showCodeMonitoring, showSearchNotebook, props.authenticatedUser])
const toolsItem = toolsItems.length > 0 && (
<NavDropdown
key="tools"

View File

@ -144,7 +144,7 @@ export const UserNavItem: FC<UserNavItemProps> = props => {
Cody dashboard
</MenuLink>
)}
<MenuLink as={Link} to={`/users/${props.authenticatedUser.username}/searches`}>
<MenuLink as={Link} to={PageRoutes.SavedSearches}>
Saved searches
</MenuLink>
{!isSourcegraphDotCom && window.context.ownEnabled && (

View File

@ -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

View File

@ -1,7 +1,7 @@
import CogOutlineIcon from 'mdi-react/CogOutlineIcon'
import FeatureSearchOutlineIcon from 'mdi-react/FeatureSearchOutlineIcon'
import { namespaceAreaHeaderNavItems } from '../../namespaces/navitems'
import { SavedSearchIcon } from '../../savedSearches/SavedSearchIcon'
import type { OrgAreaHeaderNavItem } from './OrgHeader'
@ -14,8 +14,8 @@ export const orgAreaHeaderNavItems: readonly OrgAreaHeaderNavItem[] = [
},
{
to: '/searches',
label: 'Saved searches',
icon: FeatureSearchOutlineIcon,
label: 'Saved Searches',
icon: SavedSearchIcon,
condition: ({ org: { viewerCanAdminister } }) =>
viewerCanAdminister && window.context?.codeSearchEnabledOnInstance,
},

View File

@ -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

View File

@ -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,
},
})

View File

@ -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)))

View File

@ -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}

View File

@ -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: {

View File

@ -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}

View File

@ -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', () => {

View File

@ -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}

View File

@ -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}

View File

@ -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 {

View File

@ -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)}

View File

@ -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,

View File

@ -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,

Some files were not shown because too many files have changed in this diff Show More