mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 19:21:50 +00:00
Merge branch 'main' into es/07-08-gatingaddindividualswitchesfordisablingtoolsfeatures
This commit is contained in:
commit
c9bccb5955
@ -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': [
|
||||
|
||||
@ -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,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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' } },
|
||||
],
|
||||
}
|
||||
|
||||
@ -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 }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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%;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ?? []),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 })),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 })),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -4,28 +4,27 @@ import type { MockedResponse } from '@apollo/client/testing'
|
||||
import { of } from 'rxjs'
|
||||
|
||||
import { logger } from '@sourcegraph/common'
|
||||
import { getDocumentNode, dataOrThrowErrors, useQuery } from '@sourcegraph/http-client'
|
||||
import { dataOrThrowErrors, getDocumentNode, useQuery } from '@sourcegraph/http-client'
|
||||
import { noOpTelemetryRecorder } from '@sourcegraph/shared/src/telemetry'
|
||||
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import { NOOP_PLATFORM_CONTEXT } from '@sourcegraph/shared/src/testing/searchTestHelpers'
|
||||
|
||||
import type { ConnectionQueryArguments } from '../components/FilteredConnection'
|
||||
import { asGraphQLResult } from '../components/FilteredConnection/utils'
|
||||
import {
|
||||
type UsePreciseCodeIntelForPositionResult,
|
||||
type UsePreciseCodeIntelForPositionVariables,
|
||||
HighlightResponseFormat,
|
||||
type LocationFields,
|
||||
type ReferencesPanelHighlightedBlobVariables,
|
||||
type ResolveRepoAndRevisionVariables,
|
||||
type UsePreciseCodeIntelForPositionResult,
|
||||
type UsePreciseCodeIntelForPositionVariables,
|
||||
} from '../graphql-operations'
|
||||
|
||||
import { buildPreciseLocation, LocationsGroup } from './location'
|
||||
import type { ReferencesPanelProps } from './ReferencesPanel'
|
||||
import {
|
||||
USE_PRECISE_CODE_INTEL_FOR_POSITION_QUERY,
|
||||
RESOLVE_REPO_REVISION_BLOB_QUERY,
|
||||
FETCH_HIGHLIGHTED_BLOB,
|
||||
RESOLVE_REPO_REVISION_BLOB_QUERY,
|
||||
USE_PRECISE_CODE_INTEL_FOR_POSITION_QUERY,
|
||||
} from './ReferencesPanelQueries'
|
||||
import type { UseCodeIntelParameters, UseCodeIntelResult } from './useCodeIntel'
|
||||
|
||||
@ -718,45 +717,45 @@ export const defaultProps: ReferencesPanelProps = {
|
||||
fetchMorePrototypesLoading: false,
|
||||
fetchMorePrototypes: () => {},
|
||||
})
|
||||
useQuery<
|
||||
UsePreciseCodeIntelForPositionResult,
|
||||
UsePreciseCodeIntelForPositionVariables & ConnectionQueryArguments
|
||||
>(USE_PRECISE_CODE_INTEL_FOR_POSITION_QUERY, {
|
||||
variables,
|
||||
notifyOnNetworkStatusChange: false,
|
||||
fetchPolicy: 'no-cache',
|
||||
skip: !result.loading,
|
||||
onCompleted: result => {
|
||||
const data = dataOrThrowErrors(asGraphQLResult({ data: result, errors: [] }))
|
||||
if (!data?.repository?.commit?.blob?.lsif) {
|
||||
return
|
||||
}
|
||||
const lsif = data.repository.commit.blob.lsif
|
||||
setResult(prevResult => ({
|
||||
...prevResult,
|
||||
loading: false,
|
||||
data: {
|
||||
implementations: {
|
||||
endCursor: lsif.implementations.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.implementations.nodes.map(buildPreciseLocation)),
|
||||
},
|
||||
prototypes: {
|
||||
endCursor: lsif.prototypes.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.prototypes.nodes.map(buildPreciseLocation)),
|
||||
},
|
||||
useQuery<UsePreciseCodeIntelForPositionResult, UsePreciseCodeIntelForPositionVariables>(
|
||||
USE_PRECISE_CODE_INTEL_FOR_POSITION_QUERY,
|
||||
{
|
||||
variables,
|
||||
notifyOnNetworkStatusChange: false,
|
||||
fetchPolicy: 'no-cache',
|
||||
skip: !result.loading,
|
||||
onCompleted: result => {
|
||||
const data = dataOrThrowErrors(asGraphQLResult({ data: result, errors: [] }))
|
||||
if (!data?.repository?.commit?.blob?.lsif) {
|
||||
return
|
||||
}
|
||||
const lsif = data.repository.commit.blob.lsif
|
||||
setResult(prevResult => ({
|
||||
...prevResult,
|
||||
loading: false,
|
||||
data: {
|
||||
implementations: {
|
||||
endCursor: lsif.implementations.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.implementations.nodes.map(buildPreciseLocation)),
|
||||
},
|
||||
prototypes: {
|
||||
endCursor: lsif.prototypes.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.prototypes.nodes.map(buildPreciseLocation)),
|
||||
},
|
||||
|
||||
references: {
|
||||
endCursor: lsif.references.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.references.nodes.map(buildPreciseLocation)),
|
||||
references: {
|
||||
endCursor: lsif.references.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.references.nodes.map(buildPreciseLocation)),
|
||||
},
|
||||
definitions: {
|
||||
endCursor: lsif.definitions.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.definitions.nodes.map(buildPreciseLocation)),
|
||||
},
|
||||
},
|
||||
definitions: {
|
||||
endCursor: lsif.definitions.pageInfo.endCursor,
|
||||
nodes: new LocationsGroup(lsif.definitions.nodes.map(buildPreciseLocation)),
|
||||
},
|
||||
},
|
||||
}))
|
||||
},
|
||||
})
|
||||
}))
|
||||
},
|
||||
}
|
||||
)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { ErrorLike } from '@sourcegraph/common'
|
||||
|
||||
import type { ConnectionQueryArguments } from '../components/FilteredConnection'
|
||||
import type { UsePreciseCodeIntelForPositionVariables } from '../graphql-operations'
|
||||
|
||||
import type { LocationsGroup } from './location'
|
||||
@ -44,7 +43,7 @@ export interface UseCodeIntelResult {
|
||||
}
|
||||
|
||||
export interface UseCodeIntelParameters {
|
||||
variables: UsePreciseCodeIntelForPositionVariables & ConnectionQueryArguments
|
||||
variables: UsePreciseCodeIntelForPositionVariables
|
||||
|
||||
searchToken: string
|
||||
fileContent: string
|
||||
|
||||
@ -94,25 +94,6 @@ interface ConnectionNodesProps<C extends Connection<N>, N, NP = {}, HP = {}>
|
||||
onShowMore: () => void
|
||||
}
|
||||
|
||||
export const getTotalCount = <N,>({ totalCount, nodes, pageInfo }: Connection<N>, first: number): number | null => {
|
||||
if (typeof totalCount === 'number') {
|
||||
return totalCount
|
||||
}
|
||||
|
||||
if (
|
||||
// TODO(sqs): this line below is wrong because `first` might've just been changed and
|
||||
// `nodes` is still the data fetched from before `first` was changed.
|
||||
// this causes the UI to incorrectly show "N items total" even when the count is indeterminate right
|
||||
// after the user clicks "Show more" but before the new data is loaded.
|
||||
nodes.length < first ||
|
||||
(nodes.length === first && pageInfo && typeof pageInfo.hasNextPage === 'boolean' && !pageInfo.hasNextPage)
|
||||
) {
|
||||
return nodes.length
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const ConnectionNodes = <C extends Connection<N>, N, NP = {}, HP = {}>({
|
||||
nodeComponent: NodeComponent,
|
||||
nodeComponentProps,
|
||||
@ -126,7 +107,6 @@ export const ConnectionNodes = <C extends Connection<N>, N, NP = {}, HP = {}>({
|
||||
emptyElement,
|
||||
totalCountSummaryComponent,
|
||||
connection,
|
||||
first,
|
||||
noSummaryIfAllNodesVisible,
|
||||
noun,
|
||||
pluralNoun,
|
||||
@ -142,7 +122,6 @@ export const ConnectionNodes = <C extends Connection<N>, N, NP = {}, HP = {}>({
|
||||
|
||||
const summary = (
|
||||
<ConnectionSummary
|
||||
first={first}
|
||||
noSummaryIfAllNodesVisible={noSummaryIfAllNodesVisible}
|
||||
totalCountSummaryComponent={totalCountSummaryComponent}
|
||||
noun={noun}
|
||||
|
||||
@ -1,12 +1,3 @@
|
||||
/**
|
||||
* The arguments for the connection query
|
||||
*/
|
||||
export interface ConnectionQueryArguments {
|
||||
first?: number
|
||||
after?: string | null
|
||||
query?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* See https://facebook.github.io/relay/graphql/connections.htm.
|
||||
*/
|
||||
|
||||
@ -6,15 +6,14 @@ import { RadioButtons } from '../RadioButtons'
|
||||
|
||||
import styles from './FilterControl.module.scss'
|
||||
|
||||
export type BasicFilterArgs = Record<string, string | number | boolean | null | undefined>
|
||||
|
||||
/**
|
||||
* A filter to display next to the search input field.
|
||||
* @template K The IDs of all filters ({@link Filter.id} values).
|
||||
* @template A The type of option args ({@link Filter.options} {@link FilterOption.args} values).
|
||||
* @template TKey The IDs of all filters ({@link Filter.id} values).
|
||||
* @template TArg The type of option args ({@link Filter.options} {@link FilterOption.args} values).
|
||||
*/
|
||||
export interface Filter<
|
||||
K extends string = string,
|
||||
A extends Record<string, string | number | boolean | null> = Record<string, string | number | boolean | null>
|
||||
> {
|
||||
export interface Filter<TKey extends string = string, TArg extends BasicFilterArgs = BasicFilterArgs> {
|
||||
/** The UI label for the filter. */
|
||||
label: string
|
||||
|
||||
@ -25,7 +24,7 @@ export interface Filter<
|
||||
* The URL query parameter name for this filter (conventionally the label, lowercased and
|
||||
* without spaces and punctuation).
|
||||
*/
|
||||
id: K
|
||||
id: TKey
|
||||
|
||||
/** An optional tooltip to display for this filter. */
|
||||
tooltip?: string
|
||||
@ -33,16 +32,14 @@ export interface Filter<
|
||||
/**
|
||||
* All of the possible values for this filter that the user can select.
|
||||
*/
|
||||
options: FilterOption<A>[]
|
||||
options: FilterOption<TArg>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* An option that the user can select for a filter ({@link Filter}).
|
||||
* @template A The type of option args ({@link Filter.options} {@link FilterOption.args} values).
|
||||
* @template TArg The type of option args ({@link Filter.options} {@link FilterOption.args} values).
|
||||
*/
|
||||
export interface FilterOption<
|
||||
A extends Record<string, string | number | boolean | null> = Record<string, string | number | boolean | null>
|
||||
> {
|
||||
export interface FilterOption<TArg extends BasicFilterArgs = BasicFilterArgs> {
|
||||
/**
|
||||
* The value (corresponding to the key in {@link Filter.id}) if this option is chosen. For
|
||||
* example, if a filter has {@link Filter.id} of `sort` and the user selects a
|
||||
@ -52,33 +49,31 @@ export interface FilterOption<
|
||||
value: string
|
||||
label: string
|
||||
tooltip?: string
|
||||
args: A
|
||||
args: TArg
|
||||
}
|
||||
|
||||
/**
|
||||
* The values of all filters, keyed by the filter ID ({@link Filter.id}).
|
||||
* @template K The IDs of all filters ({@link Filter.id} values).
|
||||
*/
|
||||
export type FilterValues<K extends string = string> = Record<K, FilterOption['value'] | null>
|
||||
export type FilterValues<TKey extends string = string> = Partial<Record<TKey, FilterOption['value']>>
|
||||
|
||||
interface FilterControlProps {
|
||||
/** All filters. */
|
||||
filters: Filter[]
|
||||
|
||||
/** Called when a filter is selected. */
|
||||
onValueSelect: (filter: Filter, value: FilterOption['value']) => void
|
||||
|
||||
values: FilterValues
|
||||
}
|
||||
|
||||
export const FilterControl: React.FunctionComponent<React.PropsWithChildren<FilterControlProps>> = ({
|
||||
export function FilterControl<TKey extends string = string>({
|
||||
filters,
|
||||
values,
|
||||
onValueSelect,
|
||||
children,
|
||||
}) => {
|
||||
}: React.PropsWithChildren<{
|
||||
/** All filters. */
|
||||
filters: Filter<TKey>[]
|
||||
|
||||
/** Called when a filter is selected. */
|
||||
onValueSelect: (filter: Filter<TKey>, value: FilterOption['value']) => void
|
||||
|
||||
values: FilterValues<TKey>
|
||||
}>): JSX.Element {
|
||||
const onChange = useCallback(
|
||||
(filter: Filter, id: string) => {
|
||||
(filter: Filter<TKey>, id: string) => {
|
||||
const value = filter.options.find(opt => opt.value === id)
|
||||
if (value === undefined) {
|
||||
return
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor, act } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type * as H from 'history'
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
@ -80,10 +80,10 @@ describe('FilteredConnection', () => {
|
||||
})
|
||||
|
||||
// Click "Show more" button, should cause history to be updated
|
||||
fireEvent.click(screen.getByRole('button')!)
|
||||
expect(currentLocation!.search).toEqual('?foo=bar&first=40')
|
||||
fireEvent.click(screen.getByRole('button')!)
|
||||
expect(currentLocation!.search).toEqual('?foo=bar&first=80')
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(currentLocation!.search).toEqual('?first=40&foo=bar')
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(currentLocation!.search).toEqual('?first=80&foo=bar')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -154,7 +154,7 @@ describe('ConnectionNodes', () => {
|
||||
onShowMore={showMoreCallback}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button')!)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
await waitFor(() => sinon.assert.calledOnce(showMoreCallback))
|
||||
})
|
||||
|
||||
@ -183,7 +183,7 @@ describe('ConnectionNodes', () => {
|
||||
|
||||
// Summary should come after the nodes.
|
||||
expect(
|
||||
screen.getByTestId('summary')!.compareDocumentPosition(screen.getByTestId('filtered-connection-nodes'))
|
||||
screen.getByTestId('summary').compareDocumentPosition(screen.getByTestId('filtered-connection-nodes'))
|
||||
).toEqual(Node.DOCUMENT_POSITION_PRECEDING)
|
||||
})
|
||||
|
||||
@ -221,7 +221,7 @@ describe('ConnectionNodes', () => {
|
||||
)
|
||||
// Summary should come _before_ the nodes.
|
||||
expect(
|
||||
screen.getByTestId('summary')!.compareDocumentPosition(screen.getByTestId('filtered-connection-nodes'))
|
||||
screen.getByTestId('summary').compareDocumentPosition(screen.getByTestId('filtered-connection-nodes'))
|
||||
).toEqual(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
})
|
||||
|
||||
@ -236,7 +236,7 @@ describe('ConnectionNodes', () => {
|
||||
)
|
||||
// Summary should come _before_ the nodes.
|
||||
expect(
|
||||
screen.getByTestId('summary')!.compareDocumentPosition(screen.getByTestId('filtered-connection-nodes'))
|
||||
screen.getByTestId('summary').compareDocumentPosition(screen.getByTestId('filtered-connection-nodes'))
|
||||
).toEqual(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
})
|
||||
})
|
||||
|
||||
@ -28,12 +28,13 @@ import {
|
||||
type ConnectionNodesState,
|
||||
type ConnectionProps,
|
||||
} from './ConnectionNodes'
|
||||
import type { Connection, ConnectionQueryArguments } from './ConnectionType'
|
||||
import type { Connection } from './ConnectionType'
|
||||
import { QUERY_KEY } from './constants'
|
||||
import type { Filter, FilterOption, FilterValues } from './FilterControl'
|
||||
import type { BasicFilterArgs, Filter, FilterOption, FilterValues } from './FilterControl'
|
||||
import { DEFAULT_PAGE_SIZE } from './hooks/usePageSwitcherPagination'
|
||||
import { ConnectionContainer, ConnectionError, ConnectionForm, ConnectionLoading } from './ui'
|
||||
import type { ConnectionFormProps } from './ui/ConnectionForm'
|
||||
import { getFilterFromURL, getUrlQuery, hasID, parseQueryInt } from './utils'
|
||||
import { getFilterFromURL, hasID, parseQueryInt, urlSearchParamsForFilteredConnection } from './utils'
|
||||
|
||||
/**
|
||||
* Fields that belong in FilteredConnectionProps and that don't depend on the type parameters. These are the fields
|
||||
@ -113,11 +114,7 @@ interface FilteredConnectionProps<C extends Connection<N>, N, NP = {}, HP = {}>
|
||||
queryConnection: (args: FilteredConnectionQueryArguments) => Observable<C>
|
||||
|
||||
/** Called when the queryConnection Observable emits. */
|
||||
onUpdate?: (
|
||||
value: C | ErrorLike | undefined,
|
||||
query: string,
|
||||
activeValues: Record<string, string | number | boolean | null>
|
||||
) => void
|
||||
onUpdate?: (value: C | ErrorLike | undefined, query: string, activeValues: Partial<BasicFilterArgs>) => void
|
||||
|
||||
/**
|
||||
* Set to true when the GraphQL response is expected to emit an `PageInfo.endCursor` value when
|
||||
@ -131,7 +128,11 @@ interface FilteredConnectionProps<C extends Connection<N>, N, NP = {}, HP = {}>
|
||||
/**
|
||||
* The arguments for the Props.queryConnection function.
|
||||
*/
|
||||
export interface FilteredConnectionQueryArguments extends ConnectionQueryArguments {}
|
||||
export interface FilteredConnectionQueryArguments {
|
||||
query?: string
|
||||
first?: number | null
|
||||
after?: string | null
|
||||
}
|
||||
|
||||
interface FilteredConnectionState<C extends Connection<N>, N> extends ConnectionNodesState {
|
||||
activeFilterValues: FilterValues
|
||||
@ -183,7 +184,7 @@ class InnerFilteredConnection<N, NP = {}, HP = {}, C extends Connection<N> = Con
|
||||
FilteredConnectionState<C, N>
|
||||
> {
|
||||
public static defaultProps: Partial<FilteredConnectionProps<any, any>> = {
|
||||
defaultFirst: 20,
|
||||
defaultFirst: DEFAULT_PAGE_SIZE,
|
||||
useURLQuery: true,
|
||||
}
|
||||
|
||||
@ -384,17 +385,16 @@ class InnerFilteredConnection<N, NP = {}, HP = {}, C extends Connection<N> = Con
|
||||
({ connectionOrError, previousPage, ...rest }) => {
|
||||
if (this.props.useURLQuery) {
|
||||
const { location, navigate } = this.props
|
||||
const searchFragment = this.urlQuery({ visibleResultCount: previousPage.length })
|
||||
const searchFragmentParams = new URLSearchParams(searchFragment)
|
||||
searchFragmentParams.sort()
|
||||
const newParams = this.urlQuery({ first: previousPage.length })
|
||||
newParams.sort()
|
||||
|
||||
const oldParams = new URLSearchParams(location.search)
|
||||
oldParams.sort()
|
||||
|
||||
if (!isEqual(Array.from(searchFragmentParams), Array.from(oldParams))) {
|
||||
if (!isEqual(Array.from(newParams), Array.from(oldParams))) {
|
||||
navigate(
|
||||
{
|
||||
search: searchFragment,
|
||||
search: newParams.toString(),
|
||||
hash: location.hash,
|
||||
},
|
||||
{
|
||||
@ -510,13 +510,11 @@ class InnerFilteredConnection<N, NP = {}, HP = {}, C extends Connection<N> = Con
|
||||
first,
|
||||
query,
|
||||
filterValues,
|
||||
visibleResultCount,
|
||||
}: {
|
||||
first?: number
|
||||
query?: string
|
||||
filterValues?: FilterValues
|
||||
visibleResultCount?: number
|
||||
}): string {
|
||||
}): URLSearchParams {
|
||||
if (!first) {
|
||||
first = this.state.first
|
||||
}
|
||||
@ -527,15 +525,12 @@ class InnerFilteredConnection<N, NP = {}, HP = {}, C extends Connection<N> = Con
|
||||
filterValues = this.state.activeFilterValues
|
||||
}
|
||||
|
||||
return getUrlQuery({
|
||||
return urlSearchParamsForFilteredConnection({
|
||||
query,
|
||||
first: {
|
||||
actual: first,
|
||||
// Always set through `defaultProps`
|
||||
default: this.props.defaultFirst!,
|
||||
},
|
||||
pagination: { first },
|
||||
// Always set through `defaultProps`
|
||||
pageSize: this.props.defaultFirst!,
|
||||
filterValues,
|
||||
visibleResultCount,
|
||||
search: this.props.location.search,
|
||||
filters: this.props.filters,
|
||||
})
|
||||
@ -650,7 +645,7 @@ class InnerFilteredConnection<N, NP = {}, HP = {}, C extends Connection<N> = Con
|
||||
this.queryInputChanges.next(event.currentTarget.value)
|
||||
}
|
||||
|
||||
private onDidSelectFilterValue = (filter: Filter, value: FilterOption['value'] | null): void => {
|
||||
private onDidSelectFilterValue = (filter: Filter, value: FilterOption['value'] | undefined): void => {
|
||||
if (this.props.filters === undefined) {
|
||||
return
|
||||
}
|
||||
@ -665,14 +660,14 @@ class InnerFilteredConnection<N, NP = {}, HP = {}, C extends Connection<N> = Con
|
||||
}
|
||||
|
||||
/**
|
||||
* @template K The IDs of all filters ({@link Filter.id} values).
|
||||
* @template A The type of option args ({@link Filter.options} {@link FilterOption.args} values).
|
||||
* @template TFilterKeys The IDs of all filters ({@link Filter.id} values).
|
||||
* @template TFilterArgs The type of option args ({@link Filter.options} {@link FilterOption.args} values).
|
||||
*/
|
||||
export function buildFilterArgs<
|
||||
K extends string = string,
|
||||
A extends Record<string, string | number | boolean | null> = Record<string, string | number | boolean | null>
|
||||
>(filters: Filter<K, A>[], filterValues: FilterValues<K>): A {
|
||||
let args = {} as unknown as A
|
||||
TFilterKeys extends string = string,
|
||||
TFilterArgs extends BasicFilterArgs = BasicFilterArgs
|
||||
>(filters: Filter<TFilterKeys, TFilterArgs>[], filterValues: FilterValues<TFilterKeys>): Partial<TFilterArgs> {
|
||||
let args = {} as TFilterArgs
|
||||
for (const [filterID, value] of Object.entries(filterValues)) {
|
||||
if (value === undefined) {
|
||||
continue
|
||||
|
||||
@ -0,0 +1,156 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { QUERY_KEY } from '../constants'
|
||||
import type { Filter } from '../FilterControl'
|
||||
import { getFilterFromURL, parseQueryInt, urlSearchParamsForFilteredConnection } from '../utils'
|
||||
|
||||
import type { PaginatedConnectionQueryArguments } from './usePageSwitcherPagination'
|
||||
|
||||
/**
|
||||
* The value and a setter for the value of a GraphQL connection's params.
|
||||
*/
|
||||
export type UseConnectionStateResult<TState extends PaginatedConnectionQueryArguments> = [
|
||||
connectionState: TState,
|
||||
|
||||
/**
|
||||
* Set the {@link UseConnectionStateResult.connectionState} value in a callback that receives the current
|
||||
* value as an argument. Usually callers to {@link UseConnectionStateResult.setConnectionState} will
|
||||
* want to merge values (like `updateValue(prev => ({...prev, ...newValue}))`).
|
||||
*/
|
||||
setConnectionState: (valueFunc: (current: TState) => TState) => void
|
||||
]
|
||||
|
||||
/**
|
||||
* A React hook for using the URL querystring to store the state of a paginated connection,
|
||||
* including both pagination parameters (such as `first` and `after`) and other custom filter
|
||||
* parameters.
|
||||
*/
|
||||
export function useUrlSearchParamsForConnectionState<TFilterKeys extends string>(
|
||||
filters?: Filter<TFilterKeys>[]
|
||||
): UseConnectionStateResult<
|
||||
Partial<Record<TFilterKeys, string>> & { query?: string } & PaginatedConnectionQueryArguments
|
||||
> {
|
||||
type TState = Partial<Record<TFilterKeys, string>> & { query?: string } & PaginatedConnectionQueryArguments
|
||||
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Use a ref that is set on each render so that our `setValue` callback can access the latest
|
||||
// value without having the value as one of its deps, which can cause render cycles. Note that
|
||||
// this is how `useState` works as well (the setter's function value does not change when the
|
||||
// value changes).
|
||||
const value = useRef<TState>()
|
||||
value.current = useMemo<TState>((): TState => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
|
||||
const pgParams: PaginatedConnectionQueryArguments = {
|
||||
first: parseQueryInt(params, 'first'),
|
||||
last: parseQueryInt(params, 'last'),
|
||||
after: params.get('after') ?? undefined,
|
||||
before: params.get('before') ?? undefined,
|
||||
}
|
||||
const filterParams: Partial<Record<TFilterKeys, string>> = filters
|
||||
? getFilterFromURL<TFilterKeys>(params, filters)
|
||||
: {}
|
||||
return {
|
||||
query: params.get(QUERY_KEY) ?? '',
|
||||
...pgParams,
|
||||
...filterParams,
|
||||
}
|
||||
}, [location.search, filters])
|
||||
|
||||
const locationRef = useRef<typeof location>(location)
|
||||
locationRef.current = location
|
||||
const setValue = useCallback(
|
||||
(valueFunc: (current: TState) => TState) => {
|
||||
const location = locationRef.current
|
||||
const newValue = valueFunc(value.current!)
|
||||
const params = urlSearchParamsForFilteredConnection({
|
||||
pagination: {
|
||||
first: newValue.first,
|
||||
last: newValue.last,
|
||||
after: newValue.after,
|
||||
before: newValue.before,
|
||||
},
|
||||
filters,
|
||||
filterValues: newValue,
|
||||
query: 'query' in newValue ? newValue.query : '',
|
||||
search: location.search,
|
||||
})
|
||||
navigate(
|
||||
{
|
||||
search: params.toString(),
|
||||
hash: location.hash,
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
state: location.state, // Preserve flash messages.
|
||||
}
|
||||
)
|
||||
},
|
||||
[filters, navigate]
|
||||
)
|
||||
|
||||
return [value.current, setValue]
|
||||
}
|
||||
|
||||
/**
|
||||
* A React hook for using the provided connection state (usually from
|
||||
* {@link useUrlSearchParamsForConnectionState}) if defined, or otherwise falling back to an
|
||||
* in-memory connection state implementation that does not read from and write to the URL.
|
||||
*/
|
||||
export function useConnectionStateOrMemoryFallback<
|
||||
TFilterKeys extends string,
|
||||
TState extends PaginatedConnectionQueryArguments = Record<TFilterKeys | 'query', string> &
|
||||
PaginatedConnectionQueryArguments
|
||||
>(state: UseConnectionStateResult<TState> | undefined): UseConnectionStateResult<TState> {
|
||||
const memoryState = useState<TState>({} as TState)
|
||||
return state ?? memoryState
|
||||
}
|
||||
|
||||
/**
|
||||
* A React hook that wraps the provided {@link UseConnectionStateResult} so that `?first` and
|
||||
* `?last` URL parameters are omitted if they are equal to the default page size. This makes the
|
||||
* URLs look nicer.
|
||||
*/
|
||||
export function useConnectionStateWithImplicitPageSize<
|
||||
TFilterKeys extends string,
|
||||
TState extends PaginatedConnectionQueryArguments = Record<TFilterKeys | 'query', string> &
|
||||
PaginatedConnectionQueryArguments
|
||||
>(state: UseConnectionStateResult<TState>, pageSize: number): UseConnectionStateResult<TState> {
|
||||
const [value, setValue] = state
|
||||
|
||||
// The resolved value has explicit `first` and `last`.
|
||||
const resolvedValue = useMemo<TState>(
|
||||
() => ({
|
||||
...value,
|
||||
first: value.first ?? (!value.before && !value.last ? pageSize : null),
|
||||
last: value.last ?? (value.before && !value.after && !value.first ? pageSize : null),
|
||||
}),
|
||||
[value, pageSize]
|
||||
)
|
||||
|
||||
// The setter removes `first` and `last` if they are equal to the default page size and
|
||||
// otherwise implicit.
|
||||
const setValueWithImplicits = useCallback(
|
||||
(valueFunc: (current: TState) => TState) => {
|
||||
setValue(prev => {
|
||||
const newValue = valueFunc(prev)
|
||||
return {
|
||||
...newValue,
|
||||
first:
|
||||
newValue.first === pageSize && !newValue.before && !newValue.last ? undefined : newValue.first,
|
||||
last:
|
||||
newValue.last === pageSize && newValue.before && !newValue.after && !newValue.first
|
||||
? undefined
|
||||
: newValue.last,
|
||||
}
|
||||
})
|
||||
},
|
||||
[pageSize, setValue]
|
||||
)
|
||||
|
||||
return [resolvedValue, setValueWithImplicits]
|
||||
}
|
||||
@ -5,13 +5,14 @@ import { describe, expect, it } from 'vitest'
|
||||
import { dataOrThrowErrors, getDocumentNode } from '@sourcegraph/http-client'
|
||||
import { MockedTestProvider, waitForNextApolloResponse } from '@sourcegraph/shared/src/testing/apollo'
|
||||
import { Text } from '@sourcegraph/wildcard'
|
||||
import { type RenderWithBrandedContextResult, renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
|
||||
import { renderWithBrandedContext, type RenderWithBrandedContextResult } from '@sourcegraph/wildcard/src/testing'
|
||||
|
||||
import { usePageSwitcherPagination } from './usePageSwitcherPagination'
|
||||
import { useUrlSearchParamsForConnectionState } from './connectionState'
|
||||
import { usePageSwitcherPagination, type PaginatedConnectionQueryArguments } from './usePageSwitcherPagination'
|
||||
|
||||
type TestPageSwitcherPaginationQueryFields = any
|
||||
type TestPageSwitcherPaginationQueryResult = any
|
||||
type TestPageSwitcherPaginationQueryVariables = any
|
||||
interface TestPageSwitcherPaginationQueryVariables extends PaginatedConnectionQueryArguments {}
|
||||
const TEST_PAGINATED_CONNECTION_QUERY = `
|
||||
query TestPageSwitcherPaginationQuery($first: Int, $last: Int, $after: String, $before: String) {
|
||||
savedSearchesByNamespace(
|
||||
@ -43,7 +44,8 @@ const TEST_PAGINATED_CONNECTION_QUERY = `
|
||||
|
||||
const PAGE_SIZE = 3
|
||||
|
||||
const TestComponent = ({ useURL }: { useURL: boolean }) => {
|
||||
const TestComponent = () => {
|
||||
const connectionState = useUrlSearchParamsForConnectionState([])
|
||||
const { connection, loading, goToNextPage, goToPreviousPage, goToFirstPage, goToLastPage } =
|
||||
usePageSwitcherPagination<
|
||||
TestPageSwitcherPaginationQueryResult,
|
||||
@ -57,9 +59,9 @@ const TestComponent = ({ useURL }: { useURL: boolean }) => {
|
||||
return data.savedSearchesByNamespace
|
||||
},
|
||||
options: {
|
||||
useURL,
|
||||
pageSize: PAGE_SIZE,
|
||||
},
|
||||
state: connectionState,
|
||||
})
|
||||
|
||||
return (
|
||||
@ -220,12 +222,11 @@ const generateMockCursorResponsesForEveryPage = (
|
||||
describe('usePageSwitcherPagination', () => {
|
||||
const renderWithMocks = async (
|
||||
mocks: MockedResponse<TestPageSwitcherPaginationQueryResult>[],
|
||||
useURL: boolean = true,
|
||||
initialRoute = '/'
|
||||
) => {
|
||||
const renderResult = renderWithBrandedContext(
|
||||
<MockedTestProvider mocks={mocks}>
|
||||
<TestComponent useURL={useURL} />
|
||||
<TestComponent />
|
||||
</MockedTestProvider>,
|
||||
{ route: initialRoute }
|
||||
)
|
||||
@ -303,7 +304,7 @@ describe('usePageSwitcherPagination', () => {
|
||||
})
|
||||
|
||||
it('supports restoration from forward pagination URL', async () => {
|
||||
const page = await renderWithMocks(cursorMocks, true, `/?after=${getCursorForId('6')}`)
|
||||
const page = await renderWithMocks(cursorMocks, `/?after=${getCursorForId('6')}`)
|
||||
|
||||
expect(page.getAllByRole('listitem').length).toBe(3)
|
||||
expect(page.getAllByRole('listitem')[0]).toHaveTextContent('result 7')
|
||||
@ -318,7 +319,7 @@ describe('usePageSwitcherPagination', () => {
|
||||
})
|
||||
|
||||
it('supports jumping to the first page', async () => {
|
||||
const page = await renderWithMocks(cursorMocks, true, '/?last=3')
|
||||
const page = await renderWithMocks(cursorMocks, '/?last=3')
|
||||
|
||||
await goToFirstPage(page)
|
||||
|
||||
@ -356,7 +357,7 @@ describe('usePageSwitcherPagination', () => {
|
||||
})
|
||||
|
||||
it('supports restoration from last page URL', async () => {
|
||||
const page = await renderWithMocks(cursorMocks, true, '/?last=3')
|
||||
const page = await renderWithMocks(cursorMocks, '/?last=3')
|
||||
|
||||
expect(page.getAllByRole('listitem').length).toBe(3)
|
||||
expect(page.getAllByRole('listitem')[0]).toHaveTextContent('result 8')
|
||||
@ -419,7 +420,7 @@ describe('usePageSwitcherPagination', () => {
|
||||
})
|
||||
|
||||
it('supports restoration from backward pagination URL', async () => {
|
||||
const page = await renderWithMocks(cursorMocks, true, `?before=${getCursorForId('5')}`)
|
||||
const page = await renderWithMocks(cursorMocks, `?before=${getCursorForId('5')}`)
|
||||
|
||||
expect(page.getAllByRole('listitem').length).toBe(3)
|
||||
expect(page.getAllByRole('listitem')[0]).toHaveTextContent('result 2')
|
||||
@ -432,23 +433,4 @@ describe('usePageSwitcherPagination', () => {
|
||||
expect(page.getByText('Next page')).toBeVisible()
|
||||
expect(page.getByText('Last page')).toBeVisible()
|
||||
})
|
||||
|
||||
it('does not change the URL when useURL is disabled', async () => {
|
||||
const page = await renderWithMocks(cursorMocks, false, `/?after=${getCursorForId('6')}`)
|
||||
|
||||
expect(page.getAllByRole('listitem').length).toBe(3)
|
||||
expect(page.getAllByRole('listitem')[0]).toHaveTextContent('result 1')
|
||||
expect(page.getAllByRole('listitem')[1]).toHaveTextContent('result 2')
|
||||
expect(page.getAllByRole('listitem')[2]).toHaveTextContent('result 3')
|
||||
expect(page.getByText('Total count: 10')).toBeVisible()
|
||||
|
||||
expect(page.getByText('First page')).toBeVisible()
|
||||
expect(() => page.getByText('Previous page')).toThrowError(/Unable to find an element/)
|
||||
expect(page.getByText('Next page')).toBeVisible()
|
||||
expect(page.getByText('Last page')).toBeVisible()
|
||||
|
||||
await goToLastPage(page)
|
||||
|
||||
expect(page.locationRef.current?.search).toBe(`?after=${getCursorForId('6')}`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import type { ApolloError, WatchQueryFetchPolicy } from '@apollo/client'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useQuery, type GraphQLResult } from '@sourcegraph/http-client'
|
||||
|
||||
import { asGraphQLResult } from '../utils'
|
||||
|
||||
import {
|
||||
useConnectionStateOrMemoryFallback,
|
||||
useConnectionStateWithImplicitPageSize,
|
||||
type UseConnectionStateResult,
|
||||
} from './connectionState'
|
||||
|
||||
export interface PaginatedConnectionQueryArguments {
|
||||
first?: number | null
|
||||
last?: number | null
|
||||
@ -47,28 +52,40 @@ export interface UsePaginatedConnectionResult<TResult, TVariables, TNode> extend
|
||||
}
|
||||
|
||||
interface UsePaginatedConnectionConfig<TResult> {
|
||||
// The number of items per page, defaults to 20
|
||||
/** The number of items per page. Defaults to 20. */
|
||||
pageSize?: number
|
||||
// Set if query variables should be updated in and derived from the URL
|
||||
useURL?: boolean
|
||||
// Allows modifying how the query interacts with the Apollo cache
|
||||
|
||||
/** Allows modifying how the query interacts with the Apollo cache. */
|
||||
fetchPolicy?: WatchQueryFetchPolicy
|
||||
// Allows running an optional callback on any successful request
|
||||
|
||||
/** Allows running an optional callback on any successful request. */
|
||||
onCompleted?: (data: TResult) => void
|
||||
// Allows to provide polling interval to useQuery
|
||||
|
||||
/** Allows to provide polling interval to useQuery. */
|
||||
pollInterval?: number
|
||||
}
|
||||
|
||||
export type PaginationKeys = 'first' | 'last' | 'before' | 'after'
|
||||
|
||||
interface UsePaginatedConnectionParameters<TResult, TVariables extends PaginatedConnectionQueryArguments, TNode> {
|
||||
interface UsePaginatedConnectionParameters<
|
||||
TResult,
|
||||
TVariables extends PaginatedConnectionQueryArguments,
|
||||
TNode,
|
||||
TState extends PaginatedConnectionQueryArguments
|
||||
> {
|
||||
query: string
|
||||
variables: Omit<TVariables, PaginationKeys>
|
||||
getConnection: (result: GraphQLResult<TResult>) => PaginatedConnection<TNode> | undefined
|
||||
options?: UsePaginatedConnectionConfig<TResult>
|
||||
|
||||
/**
|
||||
* The value and setter for the state parameters (such as `first`, `after`, `before`, and
|
||||
* filters).
|
||||
*/
|
||||
state?: UseConnectionStateResult<TState>
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
export const DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
/**
|
||||
* Request a GraphQL connection query and handle pagination options.
|
||||
@ -78,26 +95,38 @@ const DEFAULT_PAGE_SIZE = 20
|
||||
* @param getConnection A function that filters and returns the relevant data from the connection response.
|
||||
* @param options Additional configuration options
|
||||
*/
|
||||
export const usePageSwitcherPagination = <TResult, TVariables extends PaginatedConnectionQueryArguments, TNode>({
|
||||
export const usePageSwitcherPagination = <
|
||||
TResult,
|
||||
TVariables extends PaginatedConnectionQueryArguments,
|
||||
TNode,
|
||||
TState extends PaginatedConnectionQueryArguments = PaginatedConnectionQueryArguments &
|
||||
Partial<Record<string | 'query', string>>
|
||||
>({
|
||||
query,
|
||||
variables,
|
||||
getConnection,
|
||||
options,
|
||||
}: UsePaginatedConnectionParameters<TResult, TVariables, TNode>): UsePaginatedConnectionResult<
|
||||
state,
|
||||
}: UsePaginatedConnectionParameters<TResult, TVariables, TNode, TState>): UsePaginatedConnectionResult<
|
||||
TResult,
|
||||
TVariables,
|
||||
TNode
|
||||
> => {
|
||||
const pageSize = options?.pageSize ?? DEFAULT_PAGE_SIZE
|
||||
const [initialPaginationArgs, setPaginationArgs] = useSyncPaginationArgsWithUrl(!!options?.useURL, pageSize)
|
||||
const [connectionState, setConnectionState] = useConnectionStateWithImplicitPageSize(
|
||||
useConnectionStateOrMemoryFallback(state),
|
||||
pageSize
|
||||
)
|
||||
|
||||
// TODO(philipp-spiess): Find out why Omit<TVariables, "first" | ...> & { first: number, ... }
|
||||
// does not work here and get rid of the any cast.
|
||||
|
||||
const queryVariables: TVariables = {
|
||||
const queryVariables = {
|
||||
...variables,
|
||||
...initialPaginationArgs,
|
||||
} as any
|
||||
|
||||
// Pagination
|
||||
first: connectionState.first ?? null,
|
||||
last: connectionState.last ?? null,
|
||||
after: connectionState.after ?? null,
|
||||
before: connectionState.before ?? null,
|
||||
} as TVariables
|
||||
|
||||
const {
|
||||
data: currentData,
|
||||
@ -126,10 +155,10 @@ export const usePageSwitcherPagination = <TResult, TVariables extends PaginatedC
|
||||
|
||||
const updatePagination = useCallback(
|
||||
async (nextPageArgs: PaginatedConnectionQueryArguments): Promise<void> => {
|
||||
setPaginationArgs(nextPageArgs)
|
||||
setConnectionState(prev => ({ ...prev, ...nextPageArgs }))
|
||||
await refetch(nextPageArgs as Partial<TVariables>)
|
||||
},
|
||||
[refetch, setPaginationArgs]
|
||||
[refetch, setConnectionState]
|
||||
)
|
||||
|
||||
const goToNextPage = useCallback(async (): Promise<void> => {
|
||||
@ -184,73 +213,3 @@ export const usePageSwitcherPagination = <TResult, TVariables extends PaginatedC
|
||||
stopPolling,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(philipp-spiess): We should make these callbacks overridable by the
|
||||
// consumer of this API to allow for serialization of other query parameters in
|
||||
// the URL (e.g. filters).
|
||||
//
|
||||
// We also need to change this if we ever want to allow users to change the page
|
||||
// size and want to make it persist in the URL.
|
||||
const getPaginationArgsFromSearch = (search: string, pageSize: number): PaginatedConnectionQueryArguments => {
|
||||
const searchParameters = new URLSearchParams(search)
|
||||
|
||||
if (searchParameters.has('after')) {
|
||||
return { first: pageSize, last: null, after: searchParameters.get('after'), before: null }
|
||||
}
|
||||
if (searchParameters.has('before')) {
|
||||
return { first: null, last: pageSize, after: null, before: searchParameters.get('before') }
|
||||
}
|
||||
// Special case for handling the last page.
|
||||
if (searchParameters.has('last')) {
|
||||
return { first: null, last: pageSize, after: null, before: null }
|
||||
}
|
||||
return { first: pageSize, last: null, after: null, before: null }
|
||||
}
|
||||
const getSearchFromPaginationArgs = (paginationArgs: PaginatedConnectionQueryArguments): string => {
|
||||
const searchParameters = new URLSearchParams()
|
||||
if (paginationArgs.after) {
|
||||
searchParameters.set('after', paginationArgs.after)
|
||||
return searchParameters.toString()
|
||||
}
|
||||
if (paginationArgs.before) {
|
||||
searchParameters.set('before', paginationArgs.before)
|
||||
return searchParameters.toString()
|
||||
}
|
||||
if (paginationArgs.last) {
|
||||
searchParameters.set('last', paginationArgs.last.toString())
|
||||
return searchParameters.toString()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const useSyncPaginationArgsWithUrl = (
|
||||
enabled: boolean,
|
||||
pageSize: number
|
||||
): [
|
||||
initialPaginationArgs: PaginatedConnectionQueryArguments,
|
||||
setPaginationArgs: (args: PaginatedConnectionQueryArguments) => void
|
||||
] => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const initialPaginationArgs = useMemo(() => {
|
||||
if (enabled) {
|
||||
return getPaginationArgsFromSearch(location.search, pageSize)
|
||||
}
|
||||
return { first: pageSize, last: null, after: null, before: null }
|
||||
// We deliberately ignore changes to the URL after the first render
|
||||
// since we assume that these are caused by this hook.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled])
|
||||
|
||||
const setPaginationArgs = useCallback(
|
||||
(paginationArgs: PaginatedConnectionQueryArguments): void => {
|
||||
if (enabled) {
|
||||
const search = getSearchFromPaginationArgs(paginationArgs)
|
||||
navigate({ search }, { replace: true })
|
||||
}
|
||||
},
|
||||
[enabled, navigate]
|
||||
)
|
||||
return [initialPaginationArgs, setPaginationArgs]
|
||||
}
|
||||
|
||||
@ -5,14 +5,16 @@ import { describe, expect, it } from 'vitest'
|
||||
import { dataOrThrowErrors, getDocumentNode, gql } from '@sourcegraph/http-client'
|
||||
import { MockedTestProvider, waitForNextApolloResponse } from '@sourcegraph/shared/src/testing/apollo'
|
||||
import { Text } from '@sourcegraph/wildcard'
|
||||
import { type RenderWithBrandedContextResult, renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
|
||||
import { renderWithBrandedContext, type RenderWithBrandedContextResult } from '@sourcegraph/wildcard/src/testing'
|
||||
|
||||
import type {
|
||||
TestShowMorePaginationQueryFields,
|
||||
TestShowMorePaginationQueryResult,
|
||||
TestShowMorePaginationQueryVariables,
|
||||
} from '../../../graphql-operations'
|
||||
import type { Filter } from '../FilterControl'
|
||||
|
||||
import { useUrlSearchParamsForConnectionState } from './connectionState'
|
||||
import { useShowMorePagination } from './useShowMorePagination'
|
||||
|
||||
const TEST_SHOW_MORE_PAGINATION_QUERY = gql`
|
||||
@ -36,24 +38,26 @@ const TEST_SHOW_MORE_PAGINATION_QUERY = gql`
|
||||
}
|
||||
`
|
||||
|
||||
const FILTERS: Filter[] = []
|
||||
|
||||
const TestComponent = ({ skip = false }) => {
|
||||
const connectionState = useUrlSearchParamsForConnectionState(FILTERS)
|
||||
const { connection, fetchMore, hasNextPage } = useShowMorePagination<
|
||||
TestShowMorePaginationQueryResult,
|
||||
TestShowMorePaginationQueryVariables,
|
||||
TestShowMorePaginationQueryFields
|
||||
>({
|
||||
query: TEST_SHOW_MORE_PAGINATION_QUERY,
|
||||
variables: {
|
||||
first: 1,
|
||||
},
|
||||
variables: {},
|
||||
getConnection: result => {
|
||||
const data = dataOrThrowErrors(result)
|
||||
return data.repositories
|
||||
},
|
||||
options: {
|
||||
useURL: true,
|
||||
skip,
|
||||
pageSize: 1,
|
||||
},
|
||||
state: connectionState,
|
||||
})
|
||||
|
||||
return (
|
||||
@ -208,7 +212,7 @@ describe('useShowMorePagination', () => {
|
||||
expect(queries.getByText('Fetch more')).toBeVisible()
|
||||
|
||||
// URL updates to match visible results
|
||||
expect(queries.locationRef.current?.search).toBe('?visible=2')
|
||||
expect(queries.locationRef.current?.search).toBe('?first=2')
|
||||
})
|
||||
|
||||
it('fetches final page of results correctly', async () => {
|
||||
@ -231,12 +235,12 @@ describe('useShowMorePagination', () => {
|
||||
expect(queries.queryByText('Fetch more')).not.toBeInTheDocument()
|
||||
|
||||
// URL updates to match visible results
|
||||
expect(queries.locationRef.current?.search).toBe('?visible=4')
|
||||
expect(queries.locationRef.current?.search).toBe('?first=4')
|
||||
})
|
||||
|
||||
it('fetches correct amount of results when navigating directly with a URL', async () => {
|
||||
// We need to add an extra mock here, as we will derive a different `first` variable from `visible` in the URL.
|
||||
const mockFromVisible: MockedResponse<TestShowMorePaginationQueryResult> = {
|
||||
// We need to add an extra mock here, as we will derive a different `first` variable the URL.
|
||||
const mockFromFirst: MockedResponse<TestShowMorePaginationQueryResult> = {
|
||||
request: generateMockRequest({ first: 3 }),
|
||||
result: generateMockResult({
|
||||
nodes: [mockResultNodes[0], mockResultNodes[1], mockResultNodes[2]],
|
||||
@ -246,7 +250,7 @@ describe('useShowMorePagination', () => {
|
||||
}),
|
||||
}
|
||||
|
||||
const queries = await renderWithMocks([...cursorMocks, mockFromVisible], '/?visible=3')
|
||||
const queries = await renderWithMocks([...cursorMocks, mockFromFirst], '/?first=3')
|
||||
|
||||
// Renders 3 results without having to manually fetch
|
||||
expect(queries.getAllByRole('listitem').length).toBe(3)
|
||||
@ -263,7 +267,7 @@ describe('useShowMorePagination', () => {
|
||||
expect(queries.getByText('Total count: 4')).toBeVisible()
|
||||
|
||||
// URL should be overidden
|
||||
expect(queries.locationRef.current?.search).toBe('?visible=4')
|
||||
expect(queries.locationRef.current?.search).toBe('?first=4')
|
||||
})
|
||||
})
|
||||
|
||||
@ -287,6 +291,15 @@ describe('useShowMorePagination', () => {
|
||||
totalCount: 4,
|
||||
}),
|
||||
},
|
||||
{
|
||||
request: generateMockRequest({ first: 3 }),
|
||||
result: generateMockResult({
|
||||
nodes: [mockResultNodes[0], mockResultNodes[1], mockResultNodes[2]],
|
||||
endCursor: null,
|
||||
hasNextPage: true,
|
||||
totalCount: 4,
|
||||
}),
|
||||
},
|
||||
{
|
||||
request: generateMockRequest({ first: 4 }),
|
||||
result: generateMockResult({
|
||||
@ -330,6 +343,7 @@ describe('useShowMorePagination', () => {
|
||||
// Fetch both pages
|
||||
await fetchNextPage(queries)
|
||||
await fetchNextPage(queries)
|
||||
await fetchNextPage(queries)
|
||||
|
||||
// All pages of results are displayed
|
||||
expect(queries.getAllByRole('listitem').length).toBe(4)
|
||||
@ -354,16 +368,16 @@ describe('useShowMorePagination', () => {
|
||||
expect(queries.getByText('repo-A')).toBeVisible()
|
||||
expect(queries.getByText('repo-B')).toBeVisible()
|
||||
expect(queries.getByText('Total count: 4')).toBeVisible()
|
||||
expect(queries.locationRef.current?.search).toBe('?first=2')
|
||||
|
||||
// Fetching next page should work as usual
|
||||
await fetchNextPage(queries)
|
||||
expect(queries.getAllByRole('listitem').length).toBe(4)
|
||||
expect(queries.getAllByRole('listitem').length).toBe(3)
|
||||
expect(queries.getByText('repo-C')).toBeVisible()
|
||||
expect(queries.getByText('repo-D')).toBeVisible()
|
||||
expect(queries.getByText('Total count: 4')).toBeVisible()
|
||||
|
||||
// URL should be overidden
|
||||
expect(queries.locationRef.current?.search).toBe('?first=4')
|
||||
expect(queries.locationRef.current?.search).toBe('?first=3')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,14 +1,24 @@
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
import type { ApolloError, QueryResult, WatchQueryFetchPolicy } from '@apollo/client'
|
||||
|
||||
import { type GraphQLResult, useQuery } from '@sourcegraph/http-client'
|
||||
import { useSearchParameters, useInterval } from '@sourcegraph/wildcard'
|
||||
import { useQuery, type GraphQLResult } from '@sourcegraph/http-client'
|
||||
import { useInterval } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { Connection, ConnectionQueryArguments } from '../ConnectionType'
|
||||
import { asGraphQLResult, hasNextPage, parseQueryInt } from '../utils'
|
||||
import type { Connection } from '../ConnectionType'
|
||||
import { asGraphQLResult, hasNextPage } from '../utils'
|
||||
|
||||
import { useShowMorePaginationUrl } from './useShowMorePaginationUrl'
|
||||
import {
|
||||
useConnectionStateOrMemoryFallback,
|
||||
useConnectionStateWithImplicitPageSize,
|
||||
type UseConnectionStateResult,
|
||||
} from './connectionState'
|
||||
import { DEFAULT_PAGE_SIZE } from './usePageSwitcherPagination'
|
||||
|
||||
export interface ShowMoreConnectionQueryArguments {
|
||||
first?: number | null
|
||||
after?: string | null
|
||||
}
|
||||
|
||||
export interface UseShowMorePaginationResult<TResult, TData> {
|
||||
data?: TResult
|
||||
@ -29,12 +39,15 @@ export interface UseShowMorePaginationResult<TResult, TData> {
|
||||
}
|
||||
|
||||
interface UseShowMorePaginationConfig<TResult> {
|
||||
/** Set if query variables should be updated in and derived from the URL */
|
||||
useURL?: boolean
|
||||
/** Allows modifying how the query interacts with the Apollo cache */
|
||||
/** The number of items per page. Defaults to 20. */
|
||||
pageSize?: number
|
||||
|
||||
/** Allows modifying how the query interacts with the Apollo cache. */
|
||||
fetchPolicy?: WatchQueryFetchPolicy
|
||||
/** Allows specifying the Apollo error policy */
|
||||
|
||||
/** Allows specifying the Apollo error policy. */
|
||||
errorPolicy?: 'all' | 'none' | 'ignore'
|
||||
|
||||
/**
|
||||
* Set to enable polling of all the nodes currently loaded in the connection.
|
||||
*
|
||||
@ -43,83 +56,96 @@ interface UseShowMorePaginationConfig<TResult> {
|
||||
* the data from polling responses when the two are in flight simultaneously.
|
||||
*/
|
||||
pollInterval?: number
|
||||
/** Allows running an optional callback on any successful request */
|
||||
|
||||
/** Allows running an optional callback on any successful request. */
|
||||
onCompleted?: (data: TResult) => void
|
||||
onError?: (error: ApolloError) => void
|
||||
|
||||
// useAlternateAfterCursor is used to indicate that a custom field instead of the
|
||||
// standard "after" field is used to for pagination. This is typically a
|
||||
// workaround for existing APIs where after may already be in use for
|
||||
// another field.
|
||||
/**
|
||||
* useAlternateAfterCursor is used to indicate that a custom field instead of the
|
||||
* standard "after" field is used to for pagination. This is typically a
|
||||
* workaround for existing APIs where after may already be in use for
|
||||
* another field.
|
||||
*/
|
||||
useAlternateAfterCursor?: boolean
|
||||
/** Skip the query if this condition is true */
|
||||
|
||||
/** Skip the query if this condition is true. */
|
||||
skip?: boolean
|
||||
}
|
||||
|
||||
interface UseShowMorePaginationParameters<TResult, TVariables, TData> {
|
||||
interface UseShowMorePaginationParameters<TResult, TVariables, TData, TState extends ShowMoreConnectionQueryArguments> {
|
||||
query: string
|
||||
variables: TVariables & ConnectionQueryArguments
|
||||
variables: Omit<TVariables, keyof ShowMoreConnectionQueryArguments | 'afterCursor'>
|
||||
getConnection: (result: GraphQLResult<TResult>) => Connection<TData>
|
||||
options?: UseShowMorePaginationConfig<TResult>
|
||||
|
||||
/**
|
||||
* The value and setter for the state parameters (such as `first`, `after`, `before`, and
|
||||
* filters).
|
||||
*/
|
||||
state?: UseConnectionStateResult<TState>
|
||||
}
|
||||
|
||||
const DEFAULT_AFTER: ConnectionQueryArguments['after'] = undefined
|
||||
const DEFAULT_FIRST: ConnectionQueryArguments['first'] = 20
|
||||
|
||||
/**
|
||||
* Request a GraphQL connection query and handle pagination options.
|
||||
* Valid queries should follow the connection specification at https://relay.dev/graphql/connections.htm
|
||||
* Request a GraphQL connection query and handle pagination options. When the user presses "show
|
||||
* more", all of the previous items still remain visible. This is for GraphQL connections that only
|
||||
* support fetching results in one direction (support for `first` is required, and support for
|
||||
* `after`/`endCursor` is optional) and/or where this "show more" behavior is desirable.
|
||||
*
|
||||
* For paginated behavior (where the user can press "next page" and see a different set of results),
|
||||
* and if the GraphQL connection supports full
|
||||
* `endCursor`/`startCursor`/`after`/`before`/`first`/`last`, use {@link usePageSwitcherPagination}
|
||||
* instead.
|
||||
*
|
||||
* Valid queries should follow the connection specification at
|
||||
* https://relay.dev/graphql/connections.htm.
|
||||
* @param query The GraphQL connection query
|
||||
* @param variables The GraphQL connection variables
|
||||
* @param getConnection A function that filters and returns the relevant data from the connection response.
|
||||
* @param getConnection A function that filters and returns the relevant data from the connection
|
||||
* response.
|
||||
* @param options Additional configuration options
|
||||
*/
|
||||
export const useShowMorePagination = <TResult, TVariables extends {}, TData>({
|
||||
export const useShowMorePagination = <
|
||||
TResult,
|
||||
TVariables extends ShowMoreConnectionQueryArguments,
|
||||
TData,
|
||||
TState extends ShowMoreConnectionQueryArguments = ShowMoreConnectionQueryArguments &
|
||||
Partial<Record<string | 'query', string>>
|
||||
>({
|
||||
query,
|
||||
variables,
|
||||
getConnection: getConnectionFromGraphQLResult,
|
||||
options,
|
||||
}: UseShowMorePaginationParameters<TResult, TVariables, TData>): UseShowMorePaginationResult<TResult, TData> => {
|
||||
const searchParameters = useSearchParameters()
|
||||
|
||||
const { first = DEFAULT_FIRST, after = DEFAULT_AFTER } = variables
|
||||
const firstReference = useRef({
|
||||
/**
|
||||
* The number of results that we will typically want to load in the next request (unless `visible` is used).
|
||||
* This value will typically be static for cursor-based pagination, but will be dynamic for batch-based pagination.
|
||||
*/
|
||||
actual: (options?.useURL && parseQueryInt(searchParameters, 'first')) || first,
|
||||
/**
|
||||
* Primarily used to determine original request state for URL search parameter logic.
|
||||
*/
|
||||
default: first,
|
||||
})
|
||||
|
||||
const initialControls = useMemo(
|
||||
() => ({
|
||||
/**
|
||||
* The `first` variable for our **initial** query.
|
||||
* If this is our first query and we were supplied a value for `visible` load that many results.
|
||||
* If we weren't given such a value or this is a subsequent request, only ask for one page of results.
|
||||
*
|
||||
* 'visible' is the number of results that were visible from previous requests. The initial request of
|
||||
* a result set will load `visible` items, then will request `first` items on each subsequent
|
||||
* request. This has the effect of loading the correct number of visible results when a URL
|
||||
* is copied during pagination. This value is only useful with cursor-based paging for the initial request.
|
||||
*/
|
||||
first: (options?.useURL && parseQueryInt(searchParameters, 'visible')) || firstReference.current.actual,
|
||||
/**
|
||||
* The `after` variable for our **initial** query.
|
||||
* Subsequent requests through `fetchMore` will use a valid `cursor` value here, where possible.
|
||||
*/
|
||||
after: (options?.useURL && searchParameters.get('after')) || after,
|
||||
}),
|
||||
// We only need these controls for the initial request. We do not care about dependency updates.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
state,
|
||||
}: UseShowMorePaginationParameters<TResult, TVariables, TData, TState>): UseShowMorePaginationResult<
|
||||
TResult,
|
||||
TData
|
||||
> => {
|
||||
const pageSize = options?.pageSize ?? DEFAULT_PAGE_SIZE
|
||||
const [connectionState, setConnectionState] = useConnectionStateWithImplicitPageSize(
|
||||
useConnectionStateOrMemoryFallback(state),
|
||||
pageSize
|
||||
)
|
||||
|
||||
const first = connectionState.first ?? pageSize
|
||||
|
||||
/**
|
||||
* Map over Apollo results to provide type-compatible `GraphQLResult`s for consumers.
|
||||
* This ensures good interoperability between `FilteredConnection` and `useShowMorePagination`.
|
||||
*/
|
||||
const getConnection = ({ data, error }: Pick<QueryResult<TResult>, 'data' | 'error'>): Connection<TData> => {
|
||||
const result = asGraphQLResult({ data, errors: error?.graphQLErrors || [] })
|
||||
return getConnectionFromGraphQLResult(result)
|
||||
}
|
||||
|
||||
// These will change when the user clicks "show more", but we want those fetches to go through
|
||||
// `fetchMore` and not through `useQuery` noticing that its variables have changed, so
|
||||
// use a ref to achieve that.
|
||||
const initialPaginationArgs = useRef({
|
||||
first,
|
||||
after: connectionState.after,
|
||||
})
|
||||
|
||||
/**
|
||||
* Initial query of the hook.
|
||||
* Subsequent requests (such as further pagination) will be handled through `fetchMore`
|
||||
@ -134,8 +160,8 @@ export const useShowMorePagination = <TResult, TVariables extends {}, TData>({
|
||||
} = useQuery<TResult, TVariables>(query, {
|
||||
variables: {
|
||||
...variables,
|
||||
...initialControls,
|
||||
},
|
||||
...initialPaginationArgs.current,
|
||||
} as TVariables,
|
||||
notifyOnNetworkStatusChange: true, // Ensures loading state is updated on `fetchMore`
|
||||
skip: options?.skip,
|
||||
fetchPolicy: options?.fetchPolicy,
|
||||
@ -144,28 +170,13 @@ export const useShowMorePagination = <TResult, TVariables extends {}, TData>({
|
||||
errorPolicy: options?.errorPolicy,
|
||||
})
|
||||
|
||||
/**
|
||||
* Map over Apollo results to provide type-compatible `GraphQLResult`s for consumers.
|
||||
* This ensures good interoperability between `FilteredConnection` and `useShowMorePagination`.
|
||||
*/
|
||||
const getConnection = ({ data, error }: Pick<QueryResult<TResult>, 'data' | 'error'>): Connection<TData> => {
|
||||
const result = asGraphQLResult({ data, errors: error?.graphQLErrors || [] })
|
||||
return getConnectionFromGraphQLResult(result)
|
||||
}
|
||||
|
||||
const data = currentData ?? previousData
|
||||
const connection = data ? getConnection({ data, error }) : undefined
|
||||
|
||||
useShowMorePaginationUrl({
|
||||
enabled: options?.useURL,
|
||||
first: firstReference.current,
|
||||
visibleResultCount: connection?.nodes.length,
|
||||
})
|
||||
|
||||
const fetchMoreData = async (): Promise<void> => {
|
||||
const cursor = connection?.pageInfo?.endCursor
|
||||
|
||||
// Use cursor paging if possible, otherwise fallback to multiplying `first`.
|
||||
// Use cursor paging if possible, otherwise fallback to increasing `first`.
|
||||
const afterVariables: { after?: string; first?: number; afterCursor?: string } = {}
|
||||
if (cursor) {
|
||||
if (options?.useAlternateAfterCursor) {
|
||||
@ -173,9 +184,15 @@ export const useShowMorePagination = <TResult, TVariables extends {}, TData>({
|
||||
} else {
|
||||
afterVariables.after = cursor
|
||||
}
|
||||
afterVariables.first = pageSize
|
||||
} else {
|
||||
afterVariables.first = firstReference.current.actual * 2
|
||||
afterVariables.first = first + pageSize
|
||||
}
|
||||
|
||||
// Don't reflect `after` in the URL because the page shows *all* items from the beginning,
|
||||
// not just those after the `after` cursor. The cursor is only used in the GraphQL request.
|
||||
setConnectionState(prev => ({ ...prev, first: first + pageSize }))
|
||||
|
||||
await fetchMore({
|
||||
variables: {
|
||||
...variables,
|
||||
@ -194,9 +211,9 @@ export const useShowMorePagination = <TResult, TVariables extends {}, TData>({
|
||||
const previousNodes = getConnection({ data: previousResult }).nodes
|
||||
getConnection({ data: fetchMoreResult }).nodes.unshift(...previousNodes)
|
||||
} else {
|
||||
// With batch-based pagination, we have all the results already in `fetchMoreResult`,
|
||||
// we just need to update `first` to fetch more results next time
|
||||
firstReference.current.actual *= 2
|
||||
// With batch-based pagination, we have all the results already in
|
||||
// `fetchMoreResult`. We already updated `first` via `setConnectionState` above
|
||||
// to fetch more results next time.
|
||||
}
|
||||
|
||||
return fetchMoreResult
|
||||
@ -206,22 +223,23 @@ export const useShowMorePagination = <TResult, TVariables extends {}, TData>({
|
||||
|
||||
// Refetch the current nodes
|
||||
const refetchAll = useCallback(async (): Promise<void> => {
|
||||
const first = connection?.nodes.length || firstReference.current.actual
|
||||
|
||||
// No change in connection state (`state.setValue`) needed.
|
||||
await refetch({
|
||||
...variables,
|
||||
first,
|
||||
})
|
||||
}, [connection?.nodes.length, refetch, variables])
|
||||
} as Partial<TVariables>)
|
||||
}, [first, refetch, variables])
|
||||
|
||||
// Refetch the first page. Use this function if the number of nodes in the
|
||||
// connection might have changed since the last refetch.
|
||||
const refetchFirst = useCallback(async (): Promise<void> => {
|
||||
// Reset connection state to just fetch the first page.
|
||||
setConnectionState(prev => ({ ...prev, first: pageSize }))
|
||||
await refetch({
|
||||
...variables,
|
||||
first,
|
||||
})
|
||||
}, [first, refetch, variables])
|
||||
} as Partial<TVariables>)
|
||||
}, [first, pageSize, refetch, setConnectionState, variables])
|
||||
|
||||
// We use `refetchAll` to poll for all the nodes currently loaded in the
|
||||
// connection, vs. just providing a `pollInterval` to the underlying `useQuery`, which
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
|
||||
import { getUrlQuery, type GetUrlQueryParameters } from '../utils'
|
||||
|
||||
interface UseShowMorePaginationURLParameters extends Pick<GetUrlQueryParameters, 'first' | 'visibleResultCount'> {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook replicates how FilteredConnection updates the URL when key variables change.
|
||||
* We use this to ensure the URL is kept in sync with the current connection state.
|
||||
* This is to allow users to build complex requests that can still be shared with others.
|
||||
* It is closely coupled to useShowMorePagination, which also derives initial state from the URL.
|
||||
*/
|
||||
export const useShowMorePaginationUrl = ({
|
||||
enabled,
|
||||
first,
|
||||
visibleResultCount,
|
||||
}: UseShowMorePaginationURLParameters): void => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const searchFragment = getUrlQuery({
|
||||
first,
|
||||
visibleResultCount,
|
||||
search: location.search,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && searchFragment && location.search !== `?${searchFragment}`) {
|
||||
navigate(
|
||||
{
|
||||
search: searchFragment,
|
||||
hash: location.hash,
|
||||
},
|
||||
{ replace: true }
|
||||
)
|
||||
}
|
||||
}, [enabled, navigate, location.hash, location.search, searchFragment])
|
||||
}
|
||||
@ -9,7 +9,7 @@ import { FilterControl, type Filter, type FilterOption, type FilterValues } from
|
||||
|
||||
import styles from './ConnectionForm.module.scss'
|
||||
|
||||
export interface ConnectionFormProps {
|
||||
export interface ConnectionFormProps<TFilterKey extends string = string> {
|
||||
/** Hides the search input field. */
|
||||
hideSearch?: boolean
|
||||
|
||||
@ -42,14 +42,14 @@ export interface ConnectionFormProps {
|
||||
*
|
||||
* Filters are mutually exclusive.
|
||||
*/
|
||||
filters?: Filter[]
|
||||
filters?: Filter<TFilterKey>[]
|
||||
|
||||
onFilterSelect?: (filter: Filter, value: FilterOption['value'] | null) => void
|
||||
onFilterSelect?: (filter: Filter<TFilterKey>, value: FilterOption['value'] | undefined) => void
|
||||
|
||||
/** An element rendered as a sibling of the filters. */
|
||||
additionalFilterElement?: React.ReactElement
|
||||
|
||||
filterValues?: FilterValues
|
||||
filterValues?: FilterValues<TFilterKey>
|
||||
|
||||
compact?: boolean
|
||||
}
|
||||
@ -58,7 +58,9 @@ export interface ConnectionFormProps {
|
||||
* FilteredConnection form input.
|
||||
* Supports <input> for querying and <select>/<radio> controls for filtering
|
||||
*/
|
||||
export const ConnectionForm = React.forwardRef<HTMLInputElement, ConnectionFormProps>(
|
||||
export const ConnectionForm: <TFilterKey extends string = string>(
|
||||
props: ConnectionFormProps<TFilterKey> & { ref?: React.Ref<HTMLInputElement> }
|
||||
) => JSX.Element | null = React.forwardRef<HTMLInputElement, ConnectionFormProps<any>>(
|
||||
(
|
||||
{
|
||||
hideSearch,
|
||||
@ -122,4 +124,3 @@ export const ConnectionForm = React.forwardRef<HTMLInputElement, ConnectionFormP
|
||||
)
|
||||
}
|
||||
)
|
||||
ConnectionForm.displayName = 'ConnectionForm'
|
||||
|
||||
@ -3,7 +3,7 @@ import classNames from 'classnames'
|
||||
import { pluralize } from '@sourcegraph/common'
|
||||
import { Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import { type ConnectionNodesState, type ConnectionProps, getTotalCount } from '../ConnectionNodes'
|
||||
import type { ConnectionNodesState, ConnectionProps } from '../ConnectionNodes'
|
||||
import type { Connection } from '../ConnectionType'
|
||||
|
||||
import styles from './ConnectionSummary.module.scss'
|
||||
@ -17,7 +17,6 @@ interface ConnectionNodesSummaryProps<C extends Connection<N>, N, NP = {}, HP =
|
||||
| 'pluralNoun'
|
||||
| 'connectionQuery'
|
||||
| 'emptyElement'
|
||||
| 'first'
|
||||
> {
|
||||
/** The fetched connection data or an error (if an error occurred). */
|
||||
connection: C
|
||||
@ -44,12 +43,12 @@ export const ConnectionSummary = <C extends Connection<N>, N, NP = {}, HP = {}>(
|
||||
pluralNoun,
|
||||
connectionQuery,
|
||||
emptyElement,
|
||||
first,
|
||||
compact,
|
||||
centered,
|
||||
className,
|
||||
}: ConnectionNodesSummaryProps<C, N, NP, HP>): JSX.Element | null => {
|
||||
const shouldShowSummary = !noSummaryIfAllNodesVisible || connection.nodes.length === 0 || hasNextPage
|
||||
const shouldShowSummary =
|
||||
(!noSummaryIfAllNodesVisible && connection.nodes.length > 0) || connection.nodes.length === 0 || hasNextPage
|
||||
const summaryClassName = classNames(
|
||||
compact && styles.compact,
|
||||
centered && styles.centered,
|
||||
@ -61,8 +60,7 @@ export const ConnectionSummary = <C extends Connection<N>, N, NP = {}, HP = {}>(
|
||||
return null
|
||||
}
|
||||
|
||||
// We cannot always rely on `connection.totalCount` to be returned, fallback to `connection.nodes.length` if possible.
|
||||
const totalCount = getTotalCount(connection, first)
|
||||
const totalCount = typeof connection.totalCount === 'number' ? connection.totalCount : null
|
||||
|
||||
if (totalCount !== null && totalCount > 0 && TotalCountSummaryComponent) {
|
||||
return <TotalCountSummaryComponent totalCount={totalCount} />
|
||||
@ -94,6 +92,10 @@ export const ConnectionSummary = <C extends Connection<N>, N, NP = {}, HP = {}>(
|
||||
return null
|
||||
}
|
||||
|
||||
if (totalCount === null && connection.nodes.length > 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
emptyElement || (
|
||||
<Text className={summaryClassName} data-testid="summary">
|
||||
|
||||
188
client/web/src/components/FilteredConnection/utils.test.ts
Normal file
188
client/web/src/components/FilteredConnection/utils.test.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { Filter } from './FilterControl'
|
||||
import { getFilterFromURL, urlSearchParamsForFilteredConnection } from './utils'
|
||||
|
||||
describe('getFilterFromURL', () => {
|
||||
test('correct filter values from URL parameters', () => {
|
||||
const searchParams = new URLSearchParams('filter1=value2&filter2=value1')
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
id: 'filter1',
|
||||
label: 'Filter 1',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Option 1', value: 'value1', args: {} },
|
||||
{ label: 'Option 2', value: 'value2', args: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'filter2',
|
||||
label: 'Filter 2',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Option 1', value: 'value1', args: {} },
|
||||
{ label: 'Option 2', value: 'value2', args: {} },
|
||||
],
|
||||
},
|
||||
]
|
||||
expect(getFilterFromURL(searchParams, filters)).toEqual({
|
||||
filter1: 'value2',
|
||||
filter2: 'value1',
|
||||
})
|
||||
})
|
||||
|
||||
test('use first option value when URL parameters are not present', () => {
|
||||
const searchParams = new URLSearchParams()
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
id: 'filter1',
|
||||
label: 'Filter 1',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Option 1', value: 'value1', args: {} },
|
||||
{ label: 'Option 2', value: 'value2', args: {} },
|
||||
],
|
||||
},
|
||||
]
|
||||
expect(getFilterFromURL(searchParams, filters)).toEqual({
|
||||
filter1: 'value1',
|
||||
})
|
||||
})
|
||||
|
||||
test('return an empty object when filters are undefined', () => {
|
||||
const searchParams = new URLSearchParams('filter1=value1')
|
||||
expect(getFilterFromURL(searchParams, undefined)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('urlSearchParamsForFilteredConnection', () => {
|
||||
test('generate correct URL query string', () => {
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { first: 20 },
|
||||
pageSize: 10,
|
||||
query: 'test query',
|
||||
filterValues: { status: 'open', type: 'issue' },
|
||||
filters: [
|
||||
{
|
||||
id: 'status',
|
||||
type: 'select',
|
||||
label: 'l',
|
||||
options: [
|
||||
{ value: 'all', label: 'l', args: {} },
|
||||
{ value: 'open', label: 'l', args: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
type: 'select',
|
||||
label: 'l',
|
||||
options: [
|
||||
{ value: 'all', label: 'l', args: {} },
|
||||
{ value: 'issue', label: 'l', args: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
search: '?existing=param',
|
||||
}).toString()
|
||||
).toBe('existing=param&query=test+query&first=20&status=open&type=issue')
|
||||
})
|
||||
|
||||
test('omit default values', () => {
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { first: 10 },
|
||||
pageSize: 10,
|
||||
query: '',
|
||||
filterValues: { status: 'all' },
|
||||
filters: [
|
||||
{
|
||||
id: 'status',
|
||||
type: 'select',
|
||||
label: 'l',
|
||||
options: [
|
||||
{ value: 'all', label: 'l', args: {} },
|
||||
{ value: 'open', label: 'l', args: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
search: '',
|
||||
}).toString()
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
test('omit first/last only when implicit', () => {
|
||||
// Implicit `first`.
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { first: 10 },
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
}).toString()
|
||||
).toBe('')
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { first: 10, after: 'A' },
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
}).toString()
|
||||
).toBe('after=A')
|
||||
|
||||
// Implicit `last`.
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { last: 10, before: 'B' },
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
}).toString()
|
||||
).toBe('before=B')
|
||||
|
||||
// Non-implicit `first`.
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { first: 10, before: 'B' },
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
}).toString()
|
||||
).toBe('first=10&before=B')
|
||||
|
||||
// Non-implicit `last`.
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { last: 10 },
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
}).toString()
|
||||
).toBe('last=10')
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
pagination: { first: 10, last: 10 },
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
}).toString()
|
||||
).toBe('first=10&last=10')
|
||||
})
|
||||
|
||||
test('undefined query clears query in URL', () => {
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
query: undefined,
|
||||
search: 'query=foo',
|
||||
}).toString()
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
test('preserves existing search', () => {
|
||||
expect(
|
||||
urlSearchParamsForFilteredConnection({
|
||||
query: 'x',
|
||||
search: 'foo=bar',
|
||||
}).toString()
|
||||
).toBe('foo=bar&query=x')
|
||||
})
|
||||
|
||||
test('handle empty input', () => {
|
||||
expect(urlSearchParamsForFilteredConnection({ search: '' }).toString()).toBe('')
|
||||
})
|
||||
})
|
||||
@ -8,19 +8,27 @@ import type { Scalars } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import type { Connection } from './ConnectionType'
|
||||
import { QUERY_KEY } from './constants'
|
||||
import type { Filter, FilterValues } from './FilterControl'
|
||||
import type { PaginatedConnectionQueryArguments } from './hooks/usePageSwitcherPagination'
|
||||
|
||||
/** Checks if the passed value satisfies the GraphQL Node interface */
|
||||
export const hasID = (value: unknown): value is { id: Scalars['ID'] } =>
|
||||
typeof value === 'object' && value !== null && hasProperty('id')(value) && typeof value.id === 'string'
|
||||
export function hasID(value: unknown): value is { id: Scalars['ID'] } {
|
||||
return typeof value === 'object' && value !== null && hasProperty('id')(value) && typeof value.id === 'string'
|
||||
}
|
||||
|
||||
export const hasDisplayName = (value: unknown): value is { displayName: Scalars['String'] } =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
hasProperty('displayName')(value) &&
|
||||
typeof value.displayName === 'string'
|
||||
export function hasDisplayName(value: unknown): value is { displayName: Scalars['String'] } {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
hasProperty('displayName')(value) &&
|
||||
typeof value.displayName === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export const getFilterFromURL = (searchParameters: URLSearchParams, filters: Filter[] | undefined): FilterValues => {
|
||||
const values: FilterValues = {}
|
||||
export function getFilterFromURL<K extends string>(
|
||||
searchParameters: URLSearchParams,
|
||||
filters: Filter<K>[] | undefined
|
||||
): FilterValues<K> {
|
||||
const values: FilterValues<K> = {}
|
||||
if (filters === undefined) {
|
||||
return values
|
||||
}
|
||||
@ -39,7 +47,7 @@ export const getFilterFromURL = (searchParameters: URLSearchParams, filters: Fil
|
||||
return values
|
||||
}
|
||||
|
||||
export const parseQueryInt = (searchParameters: URLSearchParams, name: string): number | null => {
|
||||
export function parseQueryInt(searchParameters: URLSearchParams, name: string): number | null {
|
||||
const valueString = searchParameters.get(name)
|
||||
if (valueString === null) {
|
||||
return null
|
||||
@ -60,69 +68,83 @@ export const hasNextPage = (connection: Connection<unknown>): boolean =>
|
||||
? connection.pageInfo.hasNextPage
|
||||
: typeof connection.totalCount === 'number' && connection.nodes.length < connection.totalCount
|
||||
|
||||
export interface GetUrlQueryParameters {
|
||||
first?: {
|
||||
actual: number
|
||||
default: number
|
||||
}
|
||||
/**
|
||||
* Determines the URL search parameters for a connection. All of the parameters that may be used in
|
||||
* a filtered connection are handled here: search query, filters (where the URL querystring params
|
||||
* differ from the actual args that are passed as GraphQL variables), connection pagination params
|
||||
* like `first` and `after`, etc.
|
||||
*/
|
||||
export function urlSearchParamsForFilteredConnection({
|
||||
pagination,
|
||||
pageSize,
|
||||
query,
|
||||
filterValues,
|
||||
filters,
|
||||
search,
|
||||
}: {
|
||||
pagination?: PaginatedConnectionQueryArguments
|
||||
pageSize?: number
|
||||
query?: string
|
||||
filterValues?: FilterValues
|
||||
filters?: Filter[]
|
||||
visibleResultCount?: number
|
||||
search: Location['search']
|
||||
}
|
||||
}): URLSearchParams {
|
||||
const params = new URLSearchParams(search)
|
||||
|
||||
/**
|
||||
* Determines the URL search parameters for a connection.
|
||||
*/
|
||||
export const getUrlQuery = ({
|
||||
first,
|
||||
query,
|
||||
filterValues,
|
||||
visibleResultCount,
|
||||
filters,
|
||||
search,
|
||||
}: GetUrlQueryParameters): string => {
|
||||
const searchParameters = new URLSearchParams(search)
|
||||
setOrDeleteSearchParam(params, QUERY_KEY, query)
|
||||
|
||||
if (query) {
|
||||
searchParameters.set(QUERY_KEY, query)
|
||||
}
|
||||
|
||||
if (!!first && first.actual !== first.default) {
|
||||
searchParameters.set('first', String(first.actual))
|
||||
if (pagination) {
|
||||
// Omit `first` or `last` if their value is the default page size and if they are implicit
|
||||
// because it's just noise in the URL.
|
||||
const firstIfNonDefault =
|
||||
pageSize !== undefined && pagination.first === pageSize && !pagination.before && !pagination.last
|
||||
? null
|
||||
: pagination.first
|
||||
const lastIfNonDefault =
|
||||
pageSize !== undefined &&
|
||||
pagination.last === pageSize &&
|
||||
pagination.before &&
|
||||
!pagination.after &&
|
||||
!pagination.first
|
||||
? null
|
||||
: pagination.last
|
||||
setOrDeleteSearchParam(params, 'first', firstIfNonDefault)
|
||||
setOrDeleteSearchParam(params, 'last', lastIfNonDefault)
|
||||
setOrDeleteSearchParam(params, 'before', pagination.before)
|
||||
setOrDeleteSearchParam(params, 'after', pagination.after)
|
||||
}
|
||||
|
||||
if (filterValues && filters) {
|
||||
for (const filter of filters) {
|
||||
const value = filterValues[filter.id]
|
||||
if (value === undefined || value === null) {
|
||||
continue
|
||||
}
|
||||
if (value !== filter.options[0].value) {
|
||||
searchParameters.set(filter.id, value)
|
||||
const defaultValue = filter.options[0].value
|
||||
if (value !== undefined && value !== null && value !== defaultValue) {
|
||||
params.set(filter.id, value)
|
||||
} else {
|
||||
searchParameters.delete(filter.id)
|
||||
params.delete(filter.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleResultCount && visibleResultCount !== 0 && visibleResultCount !== first?.actual) {
|
||||
searchParameters.set('visible', String(visibleResultCount))
|
||||
}
|
||||
|
||||
return searchParameters.toString()
|
||||
return params
|
||||
}
|
||||
|
||||
interface AsGraphQLResultParameters<TResult> {
|
||||
data?: TResult
|
||||
errors: readonly GraphQLError[]
|
||||
function setOrDeleteSearchParam(
|
||||
params: URLSearchParams,
|
||||
name: string,
|
||||
value: string | number | null | undefined
|
||||
): void {
|
||||
if (value !== null && value !== undefined && value !== '' && value !== 0) {
|
||||
params.set(name, value.toString())
|
||||
} else {
|
||||
params.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map non-conforming GraphQL responses to a GraphQLResult.
|
||||
*/
|
||||
export const asGraphQLResult = <T>({ data, errors }: AsGraphQLResultParameters<T>): GraphQLResult<T> => {
|
||||
export function asGraphQLResult<T>({ data, errors }: { data?: T; errors: readonly GraphQLError[] }): GraphQLResult<T> {
|
||||
if (!data) {
|
||||
return { data: null, errors }
|
||||
}
|
||||
|
||||
@ -47,8 +47,6 @@ export const ExternalServicesPage: FC<Props> = ({
|
||||
const repoID = searchParameters.get('repoID') || null
|
||||
|
||||
const { loading, hasNextPage, fetchMore, connection, error } = useExternalServicesConnection({
|
||||
first: null,
|
||||
after: null,
|
||||
repo: repoID,
|
||||
})
|
||||
|
||||
@ -95,7 +93,6 @@ export const ExternalServicesPage: FC<Props> = ({
|
||||
<SummaryContainer className="mt-2" centered={true}>
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={false}
|
||||
first={connection.totalCount ?? 0}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="code host connection"
|
||||
|
||||
@ -1,39 +1,40 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
import type { QueryTuple, MutationTuple, QueryResult } from '@apollo/client'
|
||||
import type { MutationTuple, QueryResult, QueryTuple } from '@apollo/client'
|
||||
import { parse } from 'jsonc-parser'
|
||||
import { type Observable, lastValueFrom } from 'rxjs'
|
||||
import { lastValueFrom, type Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
|
||||
import { createAggregateError } from '@sourcegraph/common'
|
||||
import { gql, dataOrThrowErrors, useMutation, useLazyQuery, useQuery } from '@sourcegraph/http-client'
|
||||
import { dataOrThrowErrors, gql, useLazyQuery, useMutation, useQuery } from '@sourcegraph/http-client'
|
||||
|
||||
import { requestGraphQL } from '../../backend/graphql'
|
||||
import type {
|
||||
UpdateExternalServiceResult,
|
||||
UpdateExternalServiceVariables,
|
||||
Scalars,
|
||||
AddExternalServiceVariables,
|
||||
AddExternalServiceResult,
|
||||
DeleteExternalServiceVariables,
|
||||
DeleteExternalServiceResult,
|
||||
ExternalServicesVariables,
|
||||
ExternalServicesResult,
|
||||
ExternalServiceCheckConnectionByIdVariables,
|
||||
ExternalServiceCheckConnectionByIdResult,
|
||||
SyncExternalServiceResult,
|
||||
SyncExternalServiceVariables,
|
||||
ExternalServiceSyncJobsVariables,
|
||||
ExternalServiceSyncJobConnectionFields,
|
||||
ExternalServiceSyncJobsResult,
|
||||
CancelExternalServiceSyncVariables,
|
||||
AddExternalServiceVariables,
|
||||
CancelExternalServiceSyncResult,
|
||||
ListExternalServiceFields,
|
||||
CancelExternalServiceSyncVariables,
|
||||
DeleteExternalServiceResult,
|
||||
DeleteExternalServiceVariables,
|
||||
ExternalServiceCheckConnectionByIdResult,
|
||||
ExternalServiceCheckConnectionByIdVariables,
|
||||
ExternalServiceFields,
|
||||
ExternalServiceResult,
|
||||
ExternalServiceSyncJobConnectionFields,
|
||||
ExternalServiceSyncJobsResult,
|
||||
ExternalServiceSyncJobsVariables,
|
||||
ExternalServiceVariables,
|
||||
ExternalServicesResult,
|
||||
ExternalServicesVariables,
|
||||
ListExternalServiceFields,
|
||||
Scalars,
|
||||
SyncExternalServiceResult,
|
||||
SyncExternalServiceVariables,
|
||||
UpdateExternalServiceResult,
|
||||
UpdateExternalServiceVariables,
|
||||
} from '../../graphql-operations'
|
||||
import {
|
||||
ShowMoreConnectionQueryArguments,
|
||||
useShowMorePagination,
|
||||
type UseShowMorePaginationResult,
|
||||
} from '../FilteredConnection/hooks/useShowMorePagination'
|
||||
@ -283,11 +284,11 @@ export const EXTERNAL_SERVICE_IDS_AND_NAMES = gql`
|
||||
`
|
||||
|
||||
export const useExternalServicesConnection = (
|
||||
vars: ExternalServicesVariables
|
||||
vars: Omit<ExternalServicesVariables, keyof ShowMoreConnectionQueryArguments>
|
||||
): UseShowMorePaginationResult<ExternalServicesResult, ListExternalServiceFields> =>
|
||||
useShowMorePagination<ExternalServicesResult, ExternalServicesVariables, ListExternalServiceFields>({
|
||||
query: EXTERNAL_SERVICES,
|
||||
variables: { after: vars.after, first: vars.first ?? 10, repo: vars.repo },
|
||||
variables: { repo: vars.repo },
|
||||
getConnection: result => {
|
||||
const { externalServices } = dataOrThrowErrors(result)
|
||||
return externalServices
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState, type FC } from 'react'
|
||||
|
||||
import { mdiCog, mdiDelete, mdiOpenInNew, mdiPlus } from '@mdi/js'
|
||||
import classNames from 'classnames'
|
||||
@ -10,26 +10,26 @@ import { useQuery } from '@sourcegraph/http-client'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import {
|
||||
AnchorLink,
|
||||
Button,
|
||||
ButtonLink,
|
||||
Container,
|
||||
ErrorAlert,
|
||||
PageHeader,
|
||||
ButtonLink,
|
||||
Icon,
|
||||
LoadingSpinner,
|
||||
Button,
|
||||
Grid,
|
||||
H2,
|
||||
H3,
|
||||
Icon,
|
||||
Link,
|
||||
LoadingSpinner,
|
||||
PageHeader,
|
||||
Text,
|
||||
Grid,
|
||||
AnchorLink,
|
||||
} from '@sourcegraph/wildcard'
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import type { BreadcrumbItem } from '@sourcegraph/wildcard/src/components/PageHeader'
|
||||
|
||||
import { GitHubAppDomain, type GitHubAppByIDResult, type GitHubAppByIDVariables } from '../../graphql-operations'
|
||||
import { ExternalServiceNode } from '../externalServices/ExternalServiceNode'
|
||||
import { ConnectionList, SummaryContainer, ConnectionSummary } from '../FilteredConnection/ui'
|
||||
import { ConnectionList, ConnectionSummary, SummaryContainer } from '../FilteredConnection/ui'
|
||||
import { PageTitle } from '../PageTitle'
|
||||
|
||||
import { AppLogo } from './AppLogo'
|
||||
@ -274,7 +274,6 @@ export const GitHubAppPage: FC<Props> = ({
|
||||
<SummaryContainer className="mt-2" centered={true}>
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={false}
|
||||
first={100}
|
||||
centered={true}
|
||||
connection={installation.externalServices}
|
||||
noun="code host connection"
|
||||
@ -297,7 +296,6 @@ export const GitHubAppPage: FC<Props> = ({
|
||||
<SummaryContainer className="mt-3" centered={true}>
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={false}
|
||||
first={app?.installations?.length ?? 0}
|
||||
centered={true}
|
||||
connection={{
|
||||
nodes: app?.installations ?? [],
|
||||
|
||||
@ -9,11 +9,11 @@ import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
|
||||
import { ButtonLink, Container, ErrorAlert, Icon, Link, LoadingSpinner, PageHeader } from '@sourcegraph/wildcard'
|
||||
|
||||
import { type GitHubAppsResult, type GitHubAppsVariables, GitHubAppDomain } from '../../graphql-operations'
|
||||
import { GitHubAppDomain, type GitHubAppsResult, type GitHubAppsVariables } from '../../graphql-operations'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
ConnectionLoading,
|
||||
ConnectionList,
|
||||
ConnectionLoading,
|
||||
ConnectionSummary,
|
||||
SummaryContainer,
|
||||
} from '../FilteredConnection/ui'
|
||||
@ -103,7 +103,6 @@ export const GitHubAppsPage: React.FC<Props> = ({ batchChangesEnabled, telemetry
|
||||
<div className="text-center text-muted">You haven't created any GitHub Apps yet.</div>
|
||||
}
|
||||
noSummaryIfAllNodesVisible={false}
|
||||
first={gitHubApps?.length ?? 0}
|
||||
centered={true}
|
||||
connection={{
|
||||
nodes: gitHubApps ?? [],
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react'
|
||||
|
||||
import { mdiImport } from '@mdi/js'
|
||||
|
||||
import { Icon, H3, H4, LinkOrSpan } from '@sourcegraph/wildcard'
|
||||
import { H3, H4, Icon, LinkOrSpan } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { UseShowMorePaginationResult } from '../../../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
@ -31,8 +31,6 @@ interface ImportingChangesetsPreviewListProps {
|
||||
isStale: boolean
|
||||
}
|
||||
|
||||
const CHANGESETS_PER_PAGE_COUNT = 100
|
||||
|
||||
export const ImportingChangesetsPreviewList: React.FunctionComponent<
|
||||
React.PropsWithChildren<ImportingChangesetsPreviewListProps>
|
||||
> = ({ importingChangesetsConnection: { connection, hasNextPage, fetchMore, loading }, isStale }) => (
|
||||
@ -67,7 +65,6 @@ export const ImportingChangesetsPreviewList: React.FunctionComponent<
|
||||
<ConnectionSummary
|
||||
centered={true}
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={CHANGESETS_PER_PAGE_COUNT}
|
||||
connection={connection}
|
||||
noun="imported changeset"
|
||||
pluralNoun="imported changesets"
|
||||
|
||||
@ -6,9 +6,9 @@ import {
|
||||
ConnectionContainer,
|
||||
ConnectionError,
|
||||
ConnectionList,
|
||||
SummaryContainer,
|
||||
ConnectionSummary,
|
||||
ShowMoreButton,
|
||||
SummaryContainer,
|
||||
} from '../../../../../components/FilteredConnection/ui'
|
||||
import type {
|
||||
BatchSpecWorkspacesPreviewResult,
|
||||
@ -16,7 +16,6 @@ import type {
|
||||
PreviewVisibleBatchSpecWorkspaceFields,
|
||||
} from '../../../../../graphql-operations'
|
||||
|
||||
import { WORKSPACES_PER_PAGE_COUNT } from './useWorkspaces'
|
||||
import { WorkspacesPreviewListItem } from './WorkspacesPreviewListItem'
|
||||
|
||||
interface WorkspacesPreviewListProps {
|
||||
@ -90,7 +89,6 @@ export const WorkspacesPreviewList: React.FunctionComponent<React.PropsWithChild
|
||||
<ConnectionSummary
|
||||
centered={true}
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={WORKSPACES_PER_PAGE_COUNT}
|
||||
connection={connectionOrCached}
|
||||
noun="workspace"
|
||||
pluralNoun="workspaces"
|
||||
|
||||
@ -5,10 +5,10 @@ import {
|
||||
type UseShowMorePaginationResult,
|
||||
} from '../../../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import type {
|
||||
Scalars,
|
||||
PreviewBatchSpecImportingChangesetFields,
|
||||
BatchSpecImportingChangesetsResult,
|
||||
BatchSpecImportingChangesetsVariables,
|
||||
PreviewBatchSpecImportingChangesetFields,
|
||||
Scalars,
|
||||
} from '../../../../../graphql-operations'
|
||||
import { IMPORTING_CHANGESETS } from '../../../create/backend'
|
||||
|
||||
@ -34,11 +34,9 @@ export const useImportingChangesets = (
|
||||
query: IMPORTING_CHANGESETS,
|
||||
variables: {
|
||||
batchSpec: batchSpecID,
|
||||
after: null,
|
||||
first: CHANGESETS_PER_PAGE_COUNT,
|
||||
},
|
||||
options: {
|
||||
useURL: false,
|
||||
pageSize: CHANGESETS_PER_PAGE_COUNT,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
getConnection: result => {
|
||||
|
||||
@ -42,12 +42,10 @@ export const useWorkspaces = (
|
||||
query: WORKSPACES,
|
||||
variables: {
|
||||
batchSpec: batchSpecID,
|
||||
after: null,
|
||||
first: WORKSPACES_PER_PAGE_COUNT,
|
||||
search: filters?.search ?? null,
|
||||
},
|
||||
options: {
|
||||
useURL: false,
|
||||
pageSize: WORKSPACES_PER_PAGE_COUNT,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
getConnection: result => {
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
mdiClose,
|
||||
mdiTimelineClockOutline,
|
||||
mdiSourceBranch,
|
||||
mdiEyeOffOutline,
|
||||
mdiSync,
|
||||
mdiLinkVariantRemove,
|
||||
mdiChevronDown,
|
||||
mdiChevronUp,
|
||||
mdiClose,
|
||||
mdiEyeOffOutline,
|
||||
mdiLinkVariantRemove,
|
||||
mdiOpenInNew,
|
||||
mdiSourceBranch,
|
||||
mdiSync,
|
||||
mdiTimelineClockOutline,
|
||||
} from '@mdi/js'
|
||||
import { VisuallyHidden } from '@reach/visually-hidden'
|
||||
import classNames from 'classnames'
|
||||
@ -21,30 +21,30 @@ import type { Maybe } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
Code,
|
||||
Collapse,
|
||||
CollapseHeader,
|
||||
CollapsePanel,
|
||||
ErrorAlert,
|
||||
H1,
|
||||
H3,
|
||||
H4,
|
||||
Heading,
|
||||
Icon,
|
||||
Link,
|
||||
LoadingSpinner,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
Button,
|
||||
Link,
|
||||
CardBody,
|
||||
Card,
|
||||
Icon,
|
||||
Code,
|
||||
H1,
|
||||
H3,
|
||||
H4,
|
||||
Text,
|
||||
Alert,
|
||||
CollapsePanel,
|
||||
CollapseHeader,
|
||||
Collapse,
|
||||
Heading,
|
||||
Tooltip,
|
||||
ErrorAlert,
|
||||
} from '@sourcegraph/wildcard'
|
||||
|
||||
import { DiffStat } from '../../../../../components/diff/DiffStat'
|
||||
@ -55,23 +55,23 @@ import { HeroPage } from '../../../../../components/HeroPage'
|
||||
import { LogOutput } from '../../../../../components/LogOutput'
|
||||
import { Duration } from '../../../../../components/time/Duration'
|
||||
import {
|
||||
type BatchSpecWorkspaceChangesetSpecFields,
|
||||
BatchSpecWorkspaceState,
|
||||
type BatchSpecWorkspaceChangesetSpecFields,
|
||||
type BatchSpecWorkspaceStepFields,
|
||||
type BatchSpecWorkspaceStepResult,
|
||||
type BatchSpecWorkspaceStepVariables,
|
||||
type FileDiffFields,
|
||||
type HiddenBatchSpecWorkspaceFields,
|
||||
type Scalars,
|
||||
type VisibleBatchSpecWorkspaceFields,
|
||||
type FileDiffFields,
|
||||
type BatchSpecWorkspaceStepResult,
|
||||
type BatchSpecWorkspaceStepVariables,
|
||||
} from '../../../../../graphql-operations'
|
||||
import { queryChangesetSpecFileDiffs as _queryChangesetSpecFileDiffs } from '../../../preview/list/backend'
|
||||
import { ChangesetSpecFileDiffConnection } from '../../../preview/list/ChangesetSpecFileDiffConnection'
|
||||
import {
|
||||
useBatchSpecWorkspace,
|
||||
useRetryWorkspaceExecution,
|
||||
queryBatchSpecWorkspaceStepFileDiffs as _queryBatchSpecWorkspaceStepFileDiffs,
|
||||
BATCH_SPEC_WORKSPACE_STEP,
|
||||
useBatchSpecWorkspace,
|
||||
useRetryWorkspaceExecution,
|
||||
} from '../backend'
|
||||
import { DiagnosticsModal } from '../DiagnosticsModal'
|
||||
|
||||
@ -532,11 +532,9 @@ export const WorkspaceStepOutputLines: React.FunctionComponent<
|
||||
variables: {
|
||||
workspaceID,
|
||||
stepIndex: step.number,
|
||||
first: OUTPUT_LINES_PER_PAGE,
|
||||
after: null,
|
||||
},
|
||||
options: {
|
||||
useURL: false,
|
||||
pageSize: OUTPUT_LINES_PER_PAGE,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
getConnection: result => {
|
||||
|
||||
@ -54,7 +54,6 @@ export const BulkOperationsTab: React.FunctionComponent<React.PropsWithChildren<
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
centered={true}
|
||||
first={BATCH_COUNT}
|
||||
connection={connection}
|
||||
noun="bulk operation"
|
||||
pluralNoun="bulk operations"
|
||||
@ -89,11 +88,9 @@ const useBulkOperationsListConnection = (
|
||||
query: BULK_OPERATIONS,
|
||||
variables: {
|
||||
batchChange: batchChangeID,
|
||||
after: null,
|
||||
first: BATCH_COUNT,
|
||||
},
|
||||
options: {
|
||||
useURL: true,
|
||||
pageSize: BATCH_COUNT,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
getConnection: result => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect, useContext } from 'react'
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Subject } from 'rxjs'
|
||||
|
||||
@ -6,6 +6,7 @@ import { dataOrThrowErrors } from '@sourcegraph/http-client'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Container } from '@sourcegraph/wildcard'
|
||||
|
||||
import { useUrlSearchParamsForConnectionState } from '../../../../components/FilteredConnection/hooks/connectionState'
|
||||
import { useShowMorePagination } from '../../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
@ -17,22 +18,22 @@ import {
|
||||
SummaryContainer,
|
||||
} from '../../../../components/FilteredConnection/ui'
|
||||
import {
|
||||
BatchChangeState,
|
||||
type BatchChangeChangesetsResult,
|
||||
type BatchChangeChangesetsVariables,
|
||||
type ExternalChangesetFields,
|
||||
type HiddenExternalChangesetFields,
|
||||
type Scalars,
|
||||
type BatchChangeChangesetsResult,
|
||||
type BatchChangeChangesetsVariables,
|
||||
BatchChangeState,
|
||||
} from '../../../../graphql-operations'
|
||||
import { MultiSelectContext, MultiSelectContextProvider } from '../../MultiSelectContext'
|
||||
import {
|
||||
type queryExternalChangesetWithFileDiffs as _queryExternalChangesetWithFileDiffs,
|
||||
queryAllChangesetIDs as _queryAllChangesetIDs,
|
||||
CHANGESETS,
|
||||
queryAllChangesetIDs as _queryAllChangesetIDs,
|
||||
type queryExternalChangesetWithFileDiffs as _queryExternalChangesetWithFileDiffs,
|
||||
} from '../backend'
|
||||
|
||||
import { BatchChangeChangesetsHeader } from './BatchChangeChangesetsHeader'
|
||||
import { type ChangesetFilters, ChangesetFilterRow } from './ChangesetFilterRow'
|
||||
import { ChangesetFilterRow, type ChangesetFilters } from './ChangesetFilterRow'
|
||||
import { ChangesetNode } from './ChangesetNode'
|
||||
import { ChangesetSelectRow } from './ChangesetSelectRow'
|
||||
import { EmptyArchivedChangesetListElement } from './EmptyArchivedChangesetListElement'
|
||||
@ -127,6 +128,7 @@ const BatchChangeChangesetsImpl: React.FunctionComponent<React.PropsWithChildren
|
||||
[changesetFilters, batchChangeID, onlyArchived]
|
||||
)
|
||||
|
||||
const connectionState = useUrlSearchParamsForConnectionState()
|
||||
const { connection, error, loading, fetchMore, hasNextPage } = useShowMorePagination<
|
||||
BatchChangeChangesetsResult,
|
||||
BatchChangeChangesetsVariables,
|
||||
@ -135,12 +137,10 @@ const BatchChangeChangesetsImpl: React.FunctionComponent<React.PropsWithChildren
|
||||
query: CHANGESETS,
|
||||
variables: {
|
||||
...queryArguments,
|
||||
first: BATCH_COUNT,
|
||||
after: null,
|
||||
onlyClosable: null,
|
||||
},
|
||||
options: {
|
||||
useURL: true,
|
||||
pageSize: BATCH_COUNT,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
pollInterval: 5000,
|
||||
},
|
||||
@ -155,6 +155,7 @@ const BatchChangeChangesetsImpl: React.FunctionComponent<React.PropsWithChildren
|
||||
}
|
||||
return data.node.changesets
|
||||
},
|
||||
state: connectionState,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@ -233,7 +234,6 @@ const BatchChangeChangesetsImpl: React.FunctionComponent<React.PropsWithChildren
|
||||
<SummaryContainer centered={true}>
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={BATCH_COUNT}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="changeset"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useCallback, useState, useMemo } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import classNames from 'classnames'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
@ -10,12 +10,13 @@ import type { SettingsCascadeProps } from '@sourcegraph/shared/src/settings/sett
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
|
||||
import { Button, PageHeader, Link, Container, H3, Text, screenReaderAnnounce } from '@sourcegraph/wildcard'
|
||||
import { Button, Container, H3, Link, PageHeader, screenReaderAnnounce, Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { AuthenticatedUser } from '../../../auth'
|
||||
import { isBatchChangesExecutionEnabled } from '../../../batches'
|
||||
import { BatchChangesIcon } from '../../../batches/icons'
|
||||
import { canWriteBatchChanges, NO_ACCESS_BATCH_CHANGES_WRITE, NO_ACCESS_NAMESPACE } from '../../../batches/utils'
|
||||
import { useUrlSearchParamsForConnectionState } from '../../../components/FilteredConnection/hooks/connectionState'
|
||||
import { useShowMorePagination } from '../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
@ -28,14 +29,14 @@ import {
|
||||
} from '../../../components/FilteredConnection/ui'
|
||||
import { Page } from '../../../components/Page'
|
||||
import type {
|
||||
ListBatchChange,
|
||||
Scalars,
|
||||
BatchChangesVariables,
|
||||
BatchChangesResult,
|
||||
BatchChangesByNamespaceResult,
|
||||
BatchChangesByNamespaceVariables,
|
||||
BatchChangesResult,
|
||||
BatchChangesVariables,
|
||||
GetLicenseAndUsageInfoResult,
|
||||
GetLicenseAndUsageInfoVariables,
|
||||
ListBatchChange,
|
||||
Scalars,
|
||||
} from '../../../graphql-operations'
|
||||
|
||||
import { BATCH_CHANGES, BATCH_CHANGES_BY_NAMESPACE, GET_LICENSE_AND_USAGE_INFO } from './backend'
|
||||
@ -117,6 +118,7 @@ export const BatchChangeListPage: React.FunctionComponent<React.PropsWithChildre
|
||||
{ onCompleted: onUsageCheckCompleted }
|
||||
)
|
||||
|
||||
const connectionState = useUrlSearchParamsForConnectionState()
|
||||
const { connection, error, loading, fetchMore, hasNextPage } = useShowMorePagination<
|
||||
BatchChangesByNamespaceResult | BatchChangesResult,
|
||||
BatchChangesByNamespaceVariables | BatchChangesVariables,
|
||||
@ -124,13 +126,11 @@ export const BatchChangeListPage: React.FunctionComponent<React.PropsWithChildre
|
||||
>({
|
||||
query: namespaceID ? BATCH_CHANGES_BY_NAMESPACE : BATCH_CHANGES,
|
||||
variables: {
|
||||
namespaceID,
|
||||
...(namespaceID ? { namespaceID } : undefined),
|
||||
states: selectedFilters,
|
||||
first: BATCH_CHANGES_PER_PAGE_COUNT,
|
||||
after: null,
|
||||
viewerCanAdminister: null,
|
||||
},
|
||||
options: { useURL: true },
|
||||
options: { pageSize: BATCH_CHANGES_PER_PAGE_COUNT },
|
||||
getConnection: result => {
|
||||
const data = dataOrThrowErrors(result)
|
||||
if (!namespaceID) {
|
||||
@ -145,6 +145,7 @@ export const BatchChangeListPage: React.FunctionComponent<React.PropsWithChildre
|
||||
|
||||
return data.node.batchChanges
|
||||
},
|
||||
state: connectionState,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@ -250,7 +251,6 @@ export const BatchChangeListPage: React.FunctionComponent<React.PropsWithChildre
|
||||
<ConnectionSummary
|
||||
centered={true}
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={BATCH_CHANGES_PER_PAGE_COUNT}
|
||||
connection={connection}
|
||||
noun="batch change"
|
||||
pluralNoun="batch changes"
|
||||
|
||||
@ -17,8 +17,8 @@ import {
|
||||
} from '../../../components/FilteredConnection/ui'
|
||||
import { GitHubAppFailureAlert } from '../../../components/gitHubApps/GitHubAppFailureAlert'
|
||||
import {
|
||||
type BatchChangesCodeHostFields,
|
||||
GitHubAppKind,
|
||||
type BatchChangesCodeHostFields,
|
||||
type GlobalBatchChangesCodeHostsResult,
|
||||
type UserAreaUserFields,
|
||||
type UserBatchChangesCodeHostsResult,
|
||||
@ -111,7 +111,6 @@ const CodeHostConnections: React.FunctionComponent<React.PropsWithChildren<CodeH
|
||||
<SummaryContainer className="mt-2">
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={15}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="code host"
|
||||
|
||||
@ -17,8 +17,8 @@ import {
|
||||
} from '../../../components/FilteredConnection/ui'
|
||||
import { GitHubAppFailureAlert } from '../../../components/gitHubApps/GitHubAppFailureAlert'
|
||||
import {
|
||||
type BatchChangesCodeHostFields,
|
||||
GitHubAppKind,
|
||||
type BatchChangesCodeHostFields,
|
||||
type GlobalBatchChangesCodeHostsResult,
|
||||
type Scalars,
|
||||
type UserBatchChangesCodeHostsResult,
|
||||
@ -107,7 +107,6 @@ export const CommitSigningIntegrations: React.FunctionComponent<
|
||||
<SummaryContainer className="mt-2">
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={30}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="code host commit signing integration"
|
||||
|
||||
@ -14,11 +14,11 @@ import type {
|
||||
DeleteBatchChangesCredentialVariables,
|
||||
GlobalBatchChangesCodeHostsResult,
|
||||
GlobalBatchChangesCodeHostsVariables,
|
||||
RefreshGitHubAppResult,
|
||||
RefreshGitHubAppVariables,
|
||||
Scalars,
|
||||
UserBatchChangesCodeHostsResult,
|
||||
UserBatchChangesCodeHostsVariables,
|
||||
RefreshGitHubAppResult,
|
||||
RefreshGitHubAppVariables,
|
||||
} from '../../../graphql-operations'
|
||||
|
||||
export const CREDENTIAL_FIELDS_FRAGMENT = gql`
|
||||
@ -139,8 +139,6 @@ export const useUserBatchChangesCodeHostConnection = (
|
||||
query: USER_CODE_HOSTS,
|
||||
variables: {
|
||||
user,
|
||||
after: null,
|
||||
first: 15,
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'network-only',
|
||||
@ -179,12 +177,8 @@ export const useGlobalBatchChangesCodeHostConnection = (): UseShowMorePagination
|
||||
BatchChangesCodeHostFields
|
||||
>({
|
||||
query: GLOBAL_CODE_HOSTS,
|
||||
variables: {
|
||||
after: null,
|
||||
first: 30,
|
||||
},
|
||||
variables: {},
|
||||
options: {
|
||||
useURL: true,
|
||||
fetchPolicy: 'network-only',
|
||||
},
|
||||
getConnection: result => {
|
||||
|
||||
@ -61,7 +61,7 @@ export const CodeMonitorList: React.FunctionComponent<React.PropsWithChildren<Co
|
||||
)
|
||||
|
||||
const queryAllConnection = useCallback(
|
||||
(args: Partial<ListAllCodeMonitorsVariables>) =>
|
||||
(args: Omit<Partial<ListAllCodeMonitorsVariables>, 'first'> & { first?: number | null }) =>
|
||||
fetchCodeMonitors({
|
||||
first: args.first ?? 10,
|
||||
after: args.after ?? null,
|
||||
|
||||
@ -125,7 +125,7 @@ export const CodeMonitoringLogs: React.FunctionComponent<
|
||||
CodeMonitorWithEvents
|
||||
>({
|
||||
query: CODE_MONITOR_EVENTS,
|
||||
variables: { first: pageSize, after: null, triggerEventsFirst: runPageSize, triggerEventsAfter: null },
|
||||
variables: { triggerEventsFirst: runPageSize, triggerEventsAfter: null },
|
||||
getConnection: result => {
|
||||
const data = dataOrThrowErrors(result)
|
||||
|
||||
@ -134,6 +134,7 @@ export const CodeMonitoringLogs: React.FunctionComponent<
|
||||
}
|
||||
return data.currentUser.monitors
|
||||
},
|
||||
options: { pageSize },
|
||||
})
|
||||
|
||||
const monitors: CodeMonitorWithEvents[] = useMemo(() => connection?.nodes ?? [], [connection])
|
||||
@ -158,7 +159,6 @@ export const CodeMonitoringLogs: React.FunctionComponent<
|
||||
<SummaryContainer centered={true}>
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={pageSize}
|
||||
connection={connection}
|
||||
noun="monitor"
|
||||
pluralNoun="monitors"
|
||||
|
||||
@ -4,7 +4,7 @@ import type { QueryResult } from '@apollo/client'
|
||||
|
||||
import { dataOrThrowErrors, useLazyQuery, useQuery } from '@sourcegraph/http-client'
|
||||
|
||||
import { type Location, buildPreciseLocation, LocationsGroup } from '../../codeintel/location'
|
||||
import { buildPreciseLocation, LocationsGroup, type Location } from '../../codeintel/location'
|
||||
import {
|
||||
LOAD_ADDITIONAL_IMPLEMENTATIONS_QUERY,
|
||||
LOAD_ADDITIONAL_PROTOTYPES_QUERY,
|
||||
@ -12,17 +12,16 @@ import {
|
||||
USE_PRECISE_CODE_INTEL_FOR_POSITION_QUERY,
|
||||
} from '../../codeintel/ReferencesPanelQueries'
|
||||
import type { CodeIntelData, UseCodeIntelParameters, UseCodeIntelResult } from '../../codeintel/useCodeIntel'
|
||||
import type { ConnectionQueryArguments } from '../../components/FilteredConnection'
|
||||
import { asGraphQLResult } from '../../components/FilteredConnection/utils'
|
||||
import type {
|
||||
UsePreciseCodeIntelForPositionVariables,
|
||||
UsePreciseCodeIntelForPositionResult,
|
||||
LoadAdditionalReferencesResult,
|
||||
LoadAdditionalReferencesVariables,
|
||||
LoadAdditionalImplementationsResult,
|
||||
LoadAdditionalImplementationsVariables,
|
||||
LoadAdditionalPrototypesResult,
|
||||
LoadAdditionalPrototypesVariables,
|
||||
LoadAdditionalReferencesResult,
|
||||
LoadAdditionalReferencesVariables,
|
||||
UsePreciseCodeIntelForPositionResult,
|
||||
UsePreciseCodeIntelForPositionVariables,
|
||||
} from '../../graphql-operations'
|
||||
|
||||
import { useSearchBasedCodeIntel } from './useSearchBasedCodeIntel'
|
||||
@ -88,56 +87,56 @@ export const useCodeIntel = ({
|
||||
getSetting,
|
||||
})
|
||||
|
||||
const { error, loading } = useQuery<
|
||||
UsePreciseCodeIntelForPositionResult,
|
||||
UsePreciseCodeIntelForPositionVariables & ConnectionQueryArguments
|
||||
>(USE_PRECISE_CODE_INTEL_FOR_POSITION_QUERY, {
|
||||
variables,
|
||||
notifyOnNetworkStatusChange: false,
|
||||
fetchPolicy: 'no-cache',
|
||||
onCompleted: result => {
|
||||
if (!shouldFetchPrecise.current) {
|
||||
return
|
||||
}
|
||||
shouldFetchPrecise.current = false
|
||||
|
||||
let refs: CodeIntelData['references'] = { endCursor: null, nodes: LocationsGroup.empty }
|
||||
let defs: CodeIntelData['definitions'] = { endCursor: null, nodes: LocationsGroup.empty }
|
||||
const addRefs = (newRefs: Location[]): void => {
|
||||
refs.nodes = refs.nodes.combine(newRefs)
|
||||
}
|
||||
const addDefs = (newDefs: Location[]): void => {
|
||||
defs.nodes = defs.nodes.combine(newDefs)
|
||||
}
|
||||
|
||||
const lsifData = result ? getLsifData({ data: result }) : undefined
|
||||
if (lsifData) {
|
||||
refs = lsifData.references
|
||||
defs = lsifData.definitions
|
||||
// If we've exhausted LSIF data and the flag is enabled, we add search-based data.
|
||||
if (refs.endCursor === null && shouldMixPreciseAndSearchBasedReferences()) {
|
||||
fetchSearchBasedReferences(addRefs)
|
||||
const { error, loading } = useQuery<UsePreciseCodeIntelForPositionResult, UsePreciseCodeIntelForPositionVariables>(
|
||||
USE_PRECISE_CODE_INTEL_FOR_POSITION_QUERY,
|
||||
{
|
||||
variables,
|
||||
notifyOnNetworkStatusChange: false,
|
||||
fetchPolicy: 'no-cache',
|
||||
onCompleted: result => {
|
||||
if (!shouldFetchPrecise.current) {
|
||||
return
|
||||
}
|
||||
// When no definitions are found, the hover tooltip falls back to a search based
|
||||
// search, regardless of the mixPreciseAndSearchBasedReferences setting.
|
||||
if (defs.nodes.locationsCount === 0) {
|
||||
fetchSearchBasedDefinitions(addDefs)
|
||||
shouldFetchPrecise.current = false
|
||||
|
||||
let refs: CodeIntelData['references'] = { endCursor: null, nodes: LocationsGroup.empty }
|
||||
let defs: CodeIntelData['definitions'] = { endCursor: null, nodes: LocationsGroup.empty }
|
||||
const addRefs = (newRefs: Location[]): void => {
|
||||
refs.nodes = refs.nodes.combine(newRefs)
|
||||
}
|
||||
} else {
|
||||
fellBackToSearchBased.current = true
|
||||
fetchSearchBasedCodeIntel(addRefs, addDefs)
|
||||
}
|
||||
setCodeIntelData({
|
||||
...(lsifData || EMPTY_CODE_INTEL_DATA),
|
||||
definitions: defs,
|
||||
references: refs,
|
||||
})
|
||||
},
|
||||
})
|
||||
const addDefs = (newDefs: Location[]): void => {
|
||||
defs.nodes = defs.nodes.combine(newDefs)
|
||||
}
|
||||
|
||||
const lsifData = result ? getLsifData({ data: result }) : undefined
|
||||
if (lsifData) {
|
||||
refs = lsifData.references
|
||||
defs = lsifData.definitions
|
||||
// If we've exhausted LSIF data and the flag is enabled, we add search-based data.
|
||||
if (refs.endCursor === null && shouldMixPreciseAndSearchBasedReferences()) {
|
||||
fetchSearchBasedReferences(addRefs)
|
||||
}
|
||||
// When no definitions are found, the hover tooltip falls back to a search based
|
||||
// search, regardless of the mixPreciseAndSearchBasedReferences setting.
|
||||
if (defs.nodes.locationsCount === 0) {
|
||||
fetchSearchBasedDefinitions(addDefs)
|
||||
}
|
||||
} else {
|
||||
fellBackToSearchBased.current = true
|
||||
fetchSearchBasedCodeIntel(addRefs, addDefs)
|
||||
}
|
||||
setCodeIntelData({
|
||||
...(lsifData || EMPTY_CODE_INTEL_DATA),
|
||||
definitions: defs,
|
||||
references: refs,
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const [fetchAdditionalReferences, additionalReferencesResult] = useLazyQuery<
|
||||
LoadAdditionalReferencesResult,
|
||||
LoadAdditionalReferencesVariables & ConnectionQueryArguments
|
||||
LoadAdditionalReferencesVariables
|
||||
>(LOAD_ADDITIONAL_REFERENCES_QUERY, {
|
||||
fetchPolicy: 'no-cache',
|
||||
onCompleted: result => {
|
||||
@ -168,7 +167,7 @@ export const useCodeIntel = ({
|
||||
|
||||
const [fetchAdditionalPrototypes, additionalPrototypesResult] = useLazyQuery<
|
||||
LoadAdditionalPrototypesResult,
|
||||
LoadAdditionalPrototypesVariables & ConnectionQueryArguments
|
||||
LoadAdditionalPrototypesVariables
|
||||
>(LOAD_ADDITIONAL_PROTOTYPES_QUERY, {
|
||||
fetchPolicy: 'no-cache',
|
||||
onCompleted: result => {
|
||||
@ -189,7 +188,7 @@ export const useCodeIntel = ({
|
||||
|
||||
const [fetchAdditionalImplementations, additionalImplementationsResult] = useLazyQuery<
|
||||
LoadAdditionalImplementationsResult,
|
||||
LoadAdditionalImplementationsVariables & ConnectionQueryArguments
|
||||
LoadAdditionalImplementationsVariables
|
||||
>(LOAD_ADDITIONAL_IMPLEMENTATIONS_QUERY, {
|
||||
fetchPolicy: 'no-cache',
|
||||
onCompleted: result => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
|
||||
import { logger } from '@sourcegraph/common'
|
||||
import { Button, Modal, Input, H3, Text, Alert, Link, ErrorAlert, Form } from '@sourcegraph/wildcard'
|
||||
import { Alert, Button, ErrorAlert, Form, H3, Input, Link, Modal, Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import { LoaderButton } from '../../../components/LoaderButton'
|
||||
import type { ExecutorSecretScope, Scalars } from '../../../graphql-operations'
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import React, { type FC, useCallback, useState, useEffect } from 'react'
|
||||
import React, { useCallback, useEffect, useState, type FC } from 'react'
|
||||
|
||||
import { dataOrThrowErrors } from '@sourcegraph/http-client'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Button, Container, Link, PageHeader } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { UseShowMorePaginationResult } from '../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import { useUrlSearchParamsForConnectionState } from '../../../components/FilteredConnection/hooks/connectionState'
|
||||
import {
|
||||
useShowMorePagination,
|
||||
type UseShowMorePaginationResult,
|
||||
} from '../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
ConnectionError,
|
||||
@ -14,9 +19,10 @@ import {
|
||||
SummaryContainer,
|
||||
} from '../../../components/FilteredConnection/ui'
|
||||
import {
|
||||
type ExecutorSecretFields,
|
||||
ExecutorSecretScope,
|
||||
type ExecutorSecretFields,
|
||||
type GlobalExecutorSecretsResult,
|
||||
type GlobalExecutorSecretsVariables,
|
||||
type OrgExecutorSecretsResult,
|
||||
type Scalars,
|
||||
type UserExecutorSecretsResult,
|
||||
@ -24,9 +30,9 @@ import {
|
||||
|
||||
import { AddSecretModal } from './AddSecretModal'
|
||||
import {
|
||||
globalExecutorSecretsConnectionFactory,
|
||||
userExecutorSecretsConnectionFactory,
|
||||
GLOBAL_EXECUTOR_SECRETS,
|
||||
orgExecutorSecretsConnectionFactory,
|
||||
userExecutorSecretsConnectionFactory,
|
||||
} from './backend'
|
||||
import { ExecutorSecretNode } from './ExecutorSecretNode'
|
||||
import { ExecutorSecretScopeSelector } from './ExecutorSecretScopeSelector'
|
||||
@ -38,9 +44,27 @@ export const GlobalExecutorSecretsListPage: FC<GlobalExecutorSecretsListPageProp
|
||||
() => props.telemetryRecorder.recordEvent('admin.executors.secretsList', 'view'),
|
||||
[props.telemetryRecorder]
|
||||
)
|
||||
|
||||
const connectionState = useUrlSearchParamsForConnectionState()
|
||||
const connectionLoader = useCallback(
|
||||
(scope: ExecutorSecretScope) => globalExecutorSecretsConnectionFactory(scope),
|
||||
[]
|
||||
(scope: ExecutorSecretScope) =>
|
||||
// Scope has to be injected dynamically.
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useShowMorePagination<GlobalExecutorSecretsResult, GlobalExecutorSecretsVariables, ExecutorSecretFields>({
|
||||
query: GLOBAL_EXECUTOR_SECRETS,
|
||||
variables: {
|
||||
scope,
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'network-only',
|
||||
},
|
||||
getConnection: result => {
|
||||
const { executorSecrets } = dataOrThrowErrors(result)
|
||||
return executorSecrets
|
||||
},
|
||||
state: connectionState,
|
||||
}),
|
||||
[connectionState]
|
||||
)
|
||||
return (
|
||||
<ExecutorSecretsListPage
|
||||
@ -217,7 +241,6 @@ const ExecutorSecretsListPage: FC<ExecutorSecretsListPageProps> = ({
|
||||
<SummaryContainer className="mt-2">
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={15}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="executor secret"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
|
||||
import { Button, Modal, H3, Text } from '@sourcegraph/wildcard'
|
||||
import { Button, H3, Modal, Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import {
|
||||
ConnectionContainer,
|
||||
@ -46,7 +46,6 @@ export const SecretAccessLogsModal: React.FunctionComponent<React.PropsWithChild
|
||||
<SummaryContainer className="mt-2">
|
||||
<ConnectionSummary
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
first={15}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="access log"
|
||||
|
||||
@ -7,24 +7,22 @@ import {
|
||||
type UseShowMorePaginationResult,
|
||||
} from '../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import type {
|
||||
ExecutorSecretFields,
|
||||
Scalars,
|
||||
UserExecutorSecretsResult,
|
||||
UserExecutorSecretsVariables,
|
||||
ExecutorSecretScope,
|
||||
DeleteExecutorSecretResult,
|
||||
DeleteExecutorSecretVariables,
|
||||
GlobalExecutorSecretsResult,
|
||||
GlobalExecutorSecretsVariables,
|
||||
CreateExecutorSecretResult,
|
||||
CreateExecutorSecretVariables,
|
||||
UpdateExecutorSecretResult,
|
||||
UpdateExecutorSecretVariables,
|
||||
OrgExecutorSecretsResult,
|
||||
OrgExecutorSecretsVariables,
|
||||
DeleteExecutorSecretResult,
|
||||
DeleteExecutorSecretVariables,
|
||||
ExecutorSecretAccessLogFields,
|
||||
ExecutorSecretAccessLogsResult,
|
||||
ExecutorSecretAccessLogsVariables,
|
||||
ExecutorSecretFields,
|
||||
ExecutorSecretScope,
|
||||
OrgExecutorSecretsResult,
|
||||
OrgExecutorSecretsVariables,
|
||||
Scalars,
|
||||
UpdateExecutorSecretResult,
|
||||
UpdateExecutorSecretVariables,
|
||||
UserExecutorSecretsResult,
|
||||
UserExecutorSecretsVariables,
|
||||
} from '../../../graphql-operations'
|
||||
|
||||
const EXECUTOR_SECRET_FIELDS = gql`
|
||||
@ -127,8 +125,6 @@ export const userExecutorSecretsConnectionFactory = (
|
||||
variables: {
|
||||
user,
|
||||
scope,
|
||||
after: null,
|
||||
first: 15,
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'network-only',
|
||||
@ -173,8 +169,6 @@ export const orgExecutorSecretsConnectionFactory = (
|
||||
variables: {
|
||||
org,
|
||||
scope,
|
||||
after: null,
|
||||
first: 15,
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'network-only',
|
||||
@ -203,29 +197,6 @@ export const GLOBAL_EXECUTOR_SECRETS = gql`
|
||||
${EXECUTOR_SECRET_CONNECTION_FIELDS}
|
||||
`
|
||||
|
||||
export const globalExecutorSecretsConnectionFactory = (
|
||||
scope: ExecutorSecretScope
|
||||
): UseShowMorePaginationResult<GlobalExecutorSecretsResult, ExecutorSecretFields> =>
|
||||
// Scope has to be injected dynamically.
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useShowMorePagination<GlobalExecutorSecretsResult, GlobalExecutorSecretsVariables, ExecutorSecretFields>({
|
||||
query: GLOBAL_EXECUTOR_SECRETS,
|
||||
variables: {
|
||||
after: null,
|
||||
first: 15,
|
||||
scope,
|
||||
},
|
||||
options: {
|
||||
useURL: true,
|
||||
fetchPolicy: 'network-only',
|
||||
},
|
||||
getConnection: result => {
|
||||
const { executorSecrets } = dataOrThrowErrors(result)
|
||||
|
||||
return executorSecrets
|
||||
},
|
||||
})
|
||||
|
||||
export const EXECUTOR_SECRET_ACCESS_LOGS = gql`
|
||||
query ExecutorSecretAccessLogs($secret: ID!, $first: Int, $after: String) {
|
||||
node(id: $secret) {
|
||||
@ -273,8 +244,6 @@ export const useExecutorSecretAccessLogsConnection = (
|
||||
query: EXECUTOR_SECRET_ACCESS_LOGS,
|
||||
variables: {
|
||||
secret,
|
||||
first: 15,
|
||||
after: null,
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'network-only',
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { type ChangeEvent, type FC, useState, useEffect } from 'react'
|
||||
import { type ChangeEvent, type FC, useEffect, useState } from 'react'
|
||||
|
||||
import { mdiMapSearch } from '@mdi/js'
|
||||
|
||||
import { BackfillQueueOrderBy, InsightQueueItemState } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { type TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import {
|
||||
Container,
|
||||
ErrorAlert,
|
||||
@ -47,7 +47,7 @@ export const CodeInsightsJobs: FC<Props> = ({ telemetryRecorder }) => {
|
||||
query: GET_CODE_INSIGHTS_JOBS,
|
||||
variables: { orderBy, states: selectedFilters, search },
|
||||
getConnection: ({ data }) => data?.insightAdminBackfillQueue,
|
||||
options: { pollInterval: 10000, pageSize: 15 },
|
||||
options: { pollInterval: 10000 },
|
||||
})
|
||||
|
||||
const handleJobSelect = (event: ChangeEvent<HTMLInputElement>, jobId: string): void => {
|
||||
|
||||
@ -7,14 +7,14 @@ import { dataOrThrowErrors, getDocumentNode, gql } from '@sourcegraph/http-clien
|
||||
import type { Connection } from '../../../../../../../components/FilteredConnection'
|
||||
import { useShowMorePagination } from '../../../../../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
GroupByField,
|
||||
type AssignableInsight,
|
||||
type DashboardInsights,
|
||||
type FindInsightsBySearchTermResult,
|
||||
type FindInsightsBySearchTermVariables,
|
||||
GroupByField,
|
||||
} from '../../../../../../../graphql-operations'
|
||||
|
||||
import { type DashboardInsight, type InsightSuggestion, InsightType } from './types'
|
||||
import { InsightType, type DashboardInsight, type InsightSuggestion } from './types'
|
||||
|
||||
const SYNC_DASHBOARD_INSIGHTS = gql`
|
||||
fragment DashboardInsights on InsightsDashboard {
|
||||
@ -108,7 +108,7 @@ export function useInsightSuggestions(input: UseInsightSuggestionsInput): UseIns
|
||||
AssignableInsight | null
|
||||
>({
|
||||
query: GET_INSIGHTS_BY_SEARCH_TERM,
|
||||
variables: { first: 20, after: null, search, excludeIds },
|
||||
variables: { search, excludeIds },
|
||||
getConnection: result => {
|
||||
const { insightViews } = dataOrThrowErrors(result)
|
||||
|
||||
|
||||
@ -155,7 +155,6 @@ export const SearchJobsPage: FC<SearchJobsPageProps> = props => {
|
||||
options: {
|
||||
pollInterval: 5000,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
pageSize: 15,
|
||||
},
|
||||
getConnection: result => {
|
||||
const data = dataOrThrowErrors(result)
|
||||
|
||||
@ -41,7 +41,7 @@ export const SearchContextsList: React.FunctionComponent<SearchContextsListProps
|
||||
setAlert,
|
||||
}) => {
|
||||
const queryConnection = useCallback(
|
||||
(args: Partial<ListSearchContextsVariables>) => {
|
||||
(args: Omit<Partial<ListSearchContextsVariables>, 'first'> & { first?: number | null }) => {
|
||||
const { namespace, orderBy, descending } = args as {
|
||||
namespace: string | undefined
|
||||
orderBy: SearchContextsOrderBy
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { type Observable, Subject, Subscription } from 'rxjs'
|
||||
import { Subject, Subscription, type Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
|
||||
import { createAggregateError } from '@sourcegraph/common'
|
||||
import { gql } from '@sourcegraph/http-client'
|
||||
import type { Scalars } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
|
||||
import { Button, Link, H2, Text } from '@sourcegraph/wildcard'
|
||||
import { Button, H2, Link, Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import { requestGraphQL } from '../../backend/graphql'
|
||||
import { FilteredConnection } from '../../components/FilteredConnection'
|
||||
@ -20,8 +20,8 @@ import type {
|
||||
} from '../../graphql-operations'
|
||||
import {
|
||||
ExternalAccountNode,
|
||||
type ExternalAccountNodeProps,
|
||||
externalAccountsConnectionFragment,
|
||||
type ExternalAccountNodeProps,
|
||||
} from '../user/settings/ExternalAccountNode'
|
||||
|
||||
interface Props {}
|
||||
@ -83,7 +83,7 @@ export class SiteAdminExternalAccountsPage extends React.Component<Props> {
|
||||
|
||||
private queryExternalAccounts = (
|
||||
args: {
|
||||
first?: number
|
||||
first?: number | null
|
||||
} & FilterParameters
|
||||
): Observable<ExternalAccountsConnectionFields> =>
|
||||
requestGraphQL<ExternalAccountsResult, ExternalAccountsVariables>(
|
||||
|
||||
@ -8,12 +8,12 @@ import { Container, PageHeader } from '@sourcegraph/wildcard'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
ConnectionError,
|
||||
ConnectionLoading,
|
||||
ConnectionForm,
|
||||
ConnectionList,
|
||||
SummaryContainer,
|
||||
ConnectionLoading,
|
||||
ConnectionSummary,
|
||||
ShowMoreButton,
|
||||
ConnectionForm,
|
||||
SummaryContainer,
|
||||
} from '../../../../components/FilteredConnection/ui'
|
||||
import { PageTitle } from '../../../../components/PageTitle'
|
||||
|
||||
@ -36,10 +36,7 @@ export const SiteAdminLicenseKeyLookupPage: React.FunctionComponent<React.PropsW
|
||||
|
||||
const [search, setSearch] = useState<string>(searchParams.get(SEARCH_PARAM_KEY) ?? '')
|
||||
|
||||
const { loading, hasNextPage, fetchMore, refetchAll, connection, error } = useQueryProductLicensesConnection(
|
||||
search,
|
||||
20
|
||||
)
|
||||
const { loading, hasNextPage, fetchMore, refetchAll, connection, error } = useQueryProductLicensesConnection(search)
|
||||
|
||||
useEffect(() => {
|
||||
const query = search?.trim() ?? ''
|
||||
@ -92,7 +89,6 @@ export const SiteAdminLicenseKeyLookupPage: React.FunctionComponent<React.PropsW
|
||||
{connection && (
|
||||
<SummaryContainer className="mt-2 mb-0">
|
||||
<ConnectionSummary
|
||||
first={15}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="product license"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { mdiPlus } from '@mdi/js'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
@ -7,7 +7,7 @@ import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
|
||||
import { logger } from '@sourcegraph/common'
|
||||
import { useMutation, useQuery } from '@sourcegraph/http-client'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Button, LoadingSpinner, Link, Icon, ErrorAlert, PageHeader, Container, H3, Text } from '@sourcegraph/wildcard'
|
||||
import { Button, Container, ErrorAlert, H3, Icon, Link, LoadingSpinner, PageHeader, Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import {
|
||||
ConnectionContainer,
|
||||
@ -21,10 +21,10 @@ import {
|
||||
import { PageTitle } from '../../../../components/PageTitle'
|
||||
import { useScrollToLocationHash } from '../../../../components/useScrollToLocationHash'
|
||||
import type {
|
||||
DotComProductSubscriptionResult,
|
||||
DotComProductSubscriptionVariables,
|
||||
ArchiveProductSubscriptionResult,
|
||||
ArchiveProductSubscriptionVariables,
|
||||
DotComProductSubscriptionResult,
|
||||
DotComProductSubscriptionVariables,
|
||||
} from '../../../../graphql-operations'
|
||||
import { AccountName } from '../../../dotcom/productSubscriptions/AccountName'
|
||||
import { ProductSubscriptionLabel } from '../../../dotcom/productSubscriptions/ProductSubscriptionLabel'
|
||||
@ -39,7 +39,7 @@ import { CodyServicesSection } from './CodyServicesSection'
|
||||
import type { EnterprisePortalEnvironment } from './enterpriseportal'
|
||||
import { SiteAdminGenerateProductLicenseForSubscriptionForm } from './SiteAdminGenerateProductLicenseForSubscriptionForm'
|
||||
import { SiteAdminProductLicenseNode } from './SiteAdminProductLicenseNode'
|
||||
import { accessTokenPath, errorForPath, enterprisePortalID } from './utils'
|
||||
import { accessTokenPath, enterprisePortalID, errorForPath } from './utils'
|
||||
|
||||
interface Props extends TelemetryV2Props {}
|
||||
|
||||
@ -267,10 +267,8 @@ const ProductSubscriptionLicensesConnection: React.FunctionComponent<ProductSubs
|
||||
toggleShowGenerate,
|
||||
telemetryRecorder,
|
||||
}) => {
|
||||
const { loading, hasNextPage, fetchMore, refetchAll, connection, error } = useProductSubscriptionLicensesConnection(
|
||||
subscriptionUUID,
|
||||
20
|
||||
)
|
||||
const { loading, hasNextPage, fetchMore, refetchAll, connection, error } =
|
||||
useProductSubscriptionLicensesConnection(subscriptionUUID)
|
||||
|
||||
useEffect(() => {
|
||||
setRefetch(refetchAll)
|
||||
@ -304,7 +302,6 @@ const ProductSubscriptionLicensesConnection: React.FunctionComponent<ProductSubs
|
||||
{connection && (
|
||||
<SummaryContainer centered={true}>
|
||||
<ConnectionSummary
|
||||
first={15}
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="product license"
|
||||
|
||||
@ -5,17 +5,17 @@ import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
|
||||
import { queryGraphQL } from '../../../../backend/graphql'
|
||||
import {
|
||||
type UseShowMorePaginationResult,
|
||||
useShowMorePagination,
|
||||
type UseShowMorePaginationResult,
|
||||
} from '../../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import type {
|
||||
ProductLicensesResult,
|
||||
DotComProductLicensesResult,
|
||||
DotComProductLicensesVariables,
|
||||
ProductLicenseFields,
|
||||
ProductLicensesResult,
|
||||
ProductLicensesVariables,
|
||||
ProductSubscriptionsDotComResult,
|
||||
ProductSubscriptionsDotComVariables,
|
||||
DotComProductLicensesResult,
|
||||
DotComProductLicensesVariables,
|
||||
} from '../../../../graphql-operations'
|
||||
|
||||
const siteAdminProductSubscriptionFragment = gql`
|
||||
@ -219,14 +219,11 @@ export const PRODUCT_LICENSES = gql`
|
||||
`
|
||||
|
||||
export const useProductSubscriptionLicensesConnection = (
|
||||
subscriptionUUID: string,
|
||||
first: number
|
||||
subscriptionUUID: string
|
||||
): UseShowMorePaginationResult<ProductLicensesResult, ProductLicenseFields> =>
|
||||
useShowMorePagination<ProductLicensesResult, ProductLicensesVariables, ProductLicenseFields>({
|
||||
query: PRODUCT_LICENSES,
|
||||
variables: {
|
||||
first: first ?? 20,
|
||||
after: null,
|
||||
subscriptionUUID,
|
||||
},
|
||||
getConnection: result => {
|
||||
@ -239,7 +236,7 @@ export const useProductSubscriptionLicensesConnection = (
|
||||
})
|
||||
|
||||
export function queryProductSubscriptions(args: {
|
||||
first?: number
|
||||
first?: number | null
|
||||
query?: string
|
||||
}): Observable<ProductSubscriptionsDotComResult['dotcom']['productSubscriptions']> {
|
||||
return queryGraphQL<ProductSubscriptionsDotComResult>(
|
||||
@ -287,14 +284,11 @@ const QUERY_PRODUCT_LICENSES = gql`
|
||||
`
|
||||
|
||||
export const useQueryProductLicensesConnection = (
|
||||
licenseKeySubstring: string,
|
||||
first: number
|
||||
licenseKeySubstring: string
|
||||
): UseShowMorePaginationResult<DotComProductLicensesResult, ProductLicenseFields> =>
|
||||
useShowMorePagination<DotComProductLicensesResult, DotComProductLicensesVariables, ProductLicenseFields>({
|
||||
query: QUERY_PRODUCT_LICENSES,
|
||||
variables: {
|
||||
first: first ?? 20,
|
||||
after: null,
|
||||
licenseKeySubstring,
|
||||
},
|
||||
getConnection: result => {
|
||||
|
||||
@ -6,8 +6,8 @@ import { map } from 'rxjs/operators'
|
||||
|
||||
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Container, PageHeader, Link, Code } from '@sourcegraph/wildcard'
|
||||
import { type TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Code, Container, Link, PageHeader } from '@sourcegraph/wildcard'
|
||||
|
||||
import { requestGraphQL } from '../../../backend/graphql'
|
||||
import { FilteredConnection } from '../../../components/FilteredConnection'
|
||||
@ -86,7 +86,7 @@ export const UserEventLogsPageContent: React.FunctionComponent<
|
||||
}, [telemetryRecorder])
|
||||
|
||||
const queryUserEventLogs = useCallback(
|
||||
(args: { first?: number }): Observable<UserEventLogsConnectionFields> =>
|
||||
(args: { first?: number | null }): Observable<UserEventLogsConnectionFields> =>
|
||||
requestGraphQL<UserEventLogsResult, UserEventLogsVariables>(
|
||||
gql`
|
||||
query UserEventLogs($user: ID!, $first: Int) {
|
||||
|
||||
@ -696,8 +696,7 @@ describe('Batches', () => {
|
||||
await driver.page.waitForSelector('.test-batch-change-details-page', { visible: true })
|
||||
assert.strictEqual(
|
||||
await driver.page.evaluate(() => window.location.href),
|
||||
// We now have 1 in the cache, so we'll have a starting number visible that gets set in the URL.
|
||||
driver.sourcegraphBaseUrl + namespaceURL + '/batch-changes/test-batch-change?visible=1'
|
||||
driver.sourcegraphBaseUrl + namespaceURL + '/batch-changes/test-batch-change'
|
||||
)
|
||||
|
||||
// Delete the closed batch change.
|
||||
|
||||
@ -85,7 +85,7 @@ describe('Repository', () => {
|
||||
const shortRepositoryName = 'sourcegraph/jsonrpc2'
|
||||
const repositoryName = `github.com/${shortRepositoryName}`
|
||||
const repositorySourcegraphUrl = `/${repositoryName}`
|
||||
const commitUrl = `${repositorySourcegraphUrl}/-/commit/15c2290dcb37731cc4ee5a2a1c1e5a25b4c28f81?visible=1`
|
||||
const commitUrl = `${repositorySourcegraphUrl}/-/commit/15c2290dcb37731cc4ee5a2a1c1e5a25b4c28f81?first=1`
|
||||
const clickedFileName = 'async.go'
|
||||
const clickedCommit = ''
|
||||
const fileEntries = ['jsonrpc2.go', clickedFileName]
|
||||
@ -1107,7 +1107,7 @@ describe('Repository', () => {
|
||||
})
|
||||
|
||||
describe('Compare page', () => {
|
||||
const repositorySourcegraphUrl = `/${repositoryName}/-/compare/main...bl/readme?visible=1`
|
||||
const repositorySourcegraphUrl = `/${repositoryName}/-/compare/main...bl/readme`
|
||||
it('should render correctly compare page, including diff view', async () => {
|
||||
testContext.overrideGraphQL({
|
||||
...commonWebGraphQlResults,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
9
client/web/src/namespaces/telemetry.ts
Normal file
9
client/web/src/namespaces/telemetry.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { NamespaceProps } from '.'
|
||||
|
||||
export function namespaceTelemetryMetadata(
|
||||
namespace: Pick<NamespaceProps['namespace'], '__typename'>
|
||||
): Record<string, number> {
|
||||
return {
|
||||
[`namespaceType${namespace.__typename}`]: 1,
|
||||
}
|
||||
}
|
||||
@ -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])
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -5,15 +5,15 @@ import type { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
|
||||
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
|
||||
import { createAggregateError, numberWithCommas, memoizeObservable } from '@sourcegraph/common'
|
||||
import { createAggregateError, memoizeObservable, numberWithCommas } from '@sourcegraph/common'
|
||||
import { gql } from '@sourcegraph/http-client'
|
||||
import { Badge, Icon, LinkOrSpan } from '@sourcegraph/wildcard'
|
||||
|
||||
import { requestGraphQL } from '../backend/graphql'
|
||||
import {
|
||||
GitRefType,
|
||||
type GitRefConnectionFields,
|
||||
type GitRefFields,
|
||||
GitRefType,
|
||||
type RepositoryGitRefsResult,
|
||||
type RepositoryGitRefsVariables,
|
||||
type Scalars,
|
||||
@ -167,7 +167,7 @@ export const REPOSITORY_GIT_REFS = gql`
|
||||
export const queryGitReferences = memoizeObservable(
|
||||
(args: {
|
||||
repo: Scalars['ID']
|
||||
first?: number
|
||||
first?: number | null
|
||||
query?: string
|
||||
type: GitRefType
|
||||
withBehindAhead?: boolean
|
||||
|
||||
@ -7,7 +7,7 @@ import { useLocation } from 'react-router-dom'
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { FileSpec, RevisionSpec } from '@sourcegraph/shared/src/util/url'
|
||||
import { Icon, Link, ErrorAlert } from '@sourcegraph/wildcard'
|
||||
import { ErrorAlert, Icon, Link } from '@sourcegraph/wildcard'
|
||||
|
||||
import { useShowMorePagination } from '../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
@ -66,8 +66,6 @@ export const RepoRevisionSidebarCommits: FC<Props> = props => {
|
||||
>({
|
||||
query: FETCH_COMMITS,
|
||||
variables: {
|
||||
afterCursor: null,
|
||||
first: props.defaultPageSize || 100,
|
||||
query: '',
|
||||
repo: props.repoID,
|
||||
revision: props.revision || '',
|
||||
@ -95,6 +93,7 @@ export const RepoRevisionSidebarCommits: FC<Props> = props => {
|
||||
// will ensure that the pagination works correctly.
|
||||
useAlternateAfterCursor: true,
|
||||
fetchPolicy: 'cache-first',
|
||||
pageSize: props.defaultPageSize,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { MockedResponse } from '@apollo/client/testing'
|
||||
import { cleanup, fireEvent } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, within } from '@testing-library/react'
|
||||
import delay from 'delay'
|
||||
import { escapeRegExp } from 'lodash'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
@ -29,49 +29,79 @@ const sidebarProps: RepoRevisionSidebarSymbolsProps = {
|
||||
onHandleSymbolClick: () => {},
|
||||
}
|
||||
|
||||
const symbolsMock: MockedResponse<SymbolsResult> = {
|
||||
request: {
|
||||
query: getDocumentNode(SYMBOLS_QUERY),
|
||||
variables: {
|
||||
query: '',
|
||||
first: 100,
|
||||
repo: sidebarProps.repoID,
|
||||
revision: sidebarProps.revision,
|
||||
includePatterns: ['^' + escapeRegExp(sidebarProps.activePath)],
|
||||
const symbolsMocks: MockedResponse<SymbolsResult>[] = [
|
||||
{
|
||||
request: {
|
||||
query: getDocumentNode(SYMBOLS_QUERY),
|
||||
variables: {
|
||||
query: '',
|
||||
first: 100,
|
||||
repo: sidebarProps.repoID,
|
||||
revision: sidebarProps.revision,
|
||||
includePatterns: ['^' + escapeRegExp(sidebarProps.activePath)],
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
node: {
|
||||
__typename: 'Repository',
|
||||
commit: {
|
||||
symbols: {
|
||||
__typename: 'SymbolConnection',
|
||||
nodes: [
|
||||
{
|
||||
__typename: 'Symbol',
|
||||
kind: SymbolKind.CONSTANT,
|
||||
language: 'TypeScript',
|
||||
name: 'firstSymbol',
|
||||
url: `${location.pathname}?L13:14`,
|
||||
containerName: null,
|
||||
location: {
|
||||
resource: {
|
||||
path: 'src/index.js',
|
||||
result: {
|
||||
data: {
|
||||
node: {
|
||||
__typename: 'Repository',
|
||||
commit: {
|
||||
symbols: {
|
||||
__typename: 'SymbolConnection',
|
||||
nodes: [
|
||||
{
|
||||
__typename: 'Symbol',
|
||||
kind: SymbolKind.CONSTANT,
|
||||
language: 'TypeScript',
|
||||
name: 'firstSymbol',
|
||||
url: `${location.pathname}?L13:14`,
|
||||
containerName: null,
|
||||
location: {
|
||||
resource: {
|
||||
path: 'src/index.js',
|
||||
},
|
||||
range: null,
|
||||
},
|
||||
range: null,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
{
|
||||
request: {
|
||||
query: getDocumentNode(SYMBOLS_QUERY),
|
||||
variables: {
|
||||
query: 'some query',
|
||||
first: 100,
|
||||
repo: sidebarProps.repoID,
|
||||
revision: sidebarProps.revision,
|
||||
includePatterns: ['^' + escapeRegExp(sidebarProps.activePath)],
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
node: {
|
||||
__typename: 'Repository',
|
||||
commit: {
|
||||
symbols: {
|
||||
__typename: 'SymbolConnection',
|
||||
nodes: [],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
describe('RepoRevisionSidebarSymbols', () => {
|
||||
let renderResult: RenderWithBrandedContextResult
|
||||
@ -79,7 +109,7 @@ describe('RepoRevisionSidebarSymbols', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
renderResult = renderWithBrandedContext(
|
||||
<MockedTestProvider mocks={[symbolsMock]} addTypename={true}>
|
||||
<MockedTestProvider mocks={symbolsMocks} addTypename={true}>
|
||||
<RepoRevisionSidebarSymbols {...sidebarProps} />
|
||||
</MockedTestProvider>,
|
||||
{ route }
|
||||
@ -108,8 +138,14 @@ describe('RepoRevisionSidebarSymbols', () => {
|
||||
expect(symbol).toBeVisible()
|
||||
})
|
||||
|
||||
it('renders summary correctly', () => {
|
||||
expect(renderResult.getByText('1 symbol total')).toBeVisible()
|
||||
it('renders no-query-matches correctly', async () => {
|
||||
const searchInput = within(renderResult.container).getByRole('searchbox')
|
||||
fireEvent.change(searchInput, { target: { value: 'some query' } })
|
||||
|
||||
await waitForInputDebounce()
|
||||
await waitForNextApolloResponse()
|
||||
|
||||
expect(renderResult.getByTestId('summary')).toHaveTextContent('No symbols matching some query')
|
||||
})
|
||||
|
||||
it('clicking symbol updates route', async () => {
|
||||
@ -126,3 +162,5 @@ describe('RepoRevisionSidebarSymbols', () => {
|
||||
expect(renderResult.locationRef.current?.search).toEqual('?L13:14')
|
||||
})
|
||||
})
|
||||
|
||||
const waitForInputDebounce = () => act(() => new Promise(resolve => setTimeout(resolve, 200)))
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
import React, { useState, useMemo, Suspense } from 'react'
|
||||
import React, { Suspense, useMemo, useState } from 'react'
|
||||
|
||||
import classNames from 'classnames'
|
||||
import { escapeRegExp, groupBy } from 'lodash'
|
||||
|
||||
import { logger } from '@sourcegraph/common'
|
||||
import { gql, dataOrThrowErrors } from '@sourcegraph/http-client'
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
import type { RevisionSpec } from '@sourcegraph/shared/src/util/url'
|
||||
import { Alert, useDebounce, ErrorMessage } from '@sourcegraph/wildcard'
|
||||
import { Alert, ErrorMessage, useDebounce } from '@sourcegraph/wildcard'
|
||||
|
||||
import { useShowMorePagination } from '../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
ConnectionForm,
|
||||
ConnectionContainer,
|
||||
ConnectionForm,
|
||||
ConnectionLoading,
|
||||
ConnectionSummary,
|
||||
SummaryContainer,
|
||||
ShowMoreButton,
|
||||
SummaryContainer,
|
||||
} from '../components/FilteredConnection/ui'
|
||||
import type { Scalars, SymbolNodeFields, SymbolsResult, SymbolsVariables } from '../graphql-operations'
|
||||
|
||||
@ -101,7 +101,6 @@ export const RepoRevisionSidebarSymbols: React.FunctionComponent<
|
||||
query: SYMBOLS_QUERY,
|
||||
variables: {
|
||||
query,
|
||||
first: BATCH_COUNT,
|
||||
repo: repoID,
|
||||
revision,
|
||||
// `includePatterns` expects regexes, so first escape the path.
|
||||
@ -124,13 +123,13 @@ export const RepoRevisionSidebarSymbols: React.FunctionComponent<
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'cache-first',
|
||||
pageSize: BATCH_COUNT,
|
||||
},
|
||||
})
|
||||
|
||||
const summary = connection && (
|
||||
<ConnectionSummary
|
||||
connection={connection}
|
||||
first={BATCH_COUNT}
|
||||
noun="symbol"
|
||||
pluralNoun="symbols"
|
||||
hasNextPage={hasNextPage}
|
||||
|
||||
@ -4,7 +4,7 @@ import { getDocumentNode } from '@sourcegraph/http-client'
|
||||
|
||||
import type { RepositoriesForPopoverResult, RepositoryPopoverFields } from '../../graphql-operations'
|
||||
|
||||
import { REPOSITORIES_FOR_POPOVER, BATCH_COUNT } from './RepositoriesPopover'
|
||||
import { BATCH_COUNT, REPOSITORIES_FOR_POPOVER } from './RepositoriesPopover'
|
||||
|
||||
interface GenerateRepositoryNodesParameters {
|
||||
count: number
|
||||
@ -29,7 +29,6 @@ const repositoriesMock: MockedResponse<RepositoriesForPopoverResult> = {
|
||||
variables: {
|
||||
query: '',
|
||||
first: BATCH_COUNT,
|
||||
after: null,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
@ -73,7 +72,6 @@ const filteredRepositoriesMock: MockedResponse<RepositoriesForPopoverResult> = {
|
||||
variables: {
|
||||
query: 'some query',
|
||||
first: BATCH_COUNT,
|
||||
after: null,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
@ -95,7 +93,6 @@ const additionalFilteredRepositoriesMock: MockedResponse<RepositoriesForPopoverR
|
||||
variables: {
|
||||
query: 'some other query',
|
||||
first: BATCH_COUNT,
|
||||
after: null,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
|
||||
@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react'
|
||||
import { createAggregateError } from '@sourcegraph/common'
|
||||
import { gql } from '@sourcegraph/http-client'
|
||||
import type { Scalars } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { type TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
|
||||
import { useDebounce } from '@sourcegraph/wildcard'
|
||||
@ -82,7 +82,7 @@ export const RepositoriesPopover: React.FunctionComponent<React.PropsWithChildre
|
||||
RepositoryPopoverFields
|
||||
>({
|
||||
query: REPOSITORIES_FOR_POPOVER,
|
||||
variables: { first: BATCH_COUNT, after: null, query },
|
||||
variables: { query },
|
||||
getConnection: ({ data, errors }) => {
|
||||
if (!data?.repositories) {
|
||||
throw createAggregateError(errors)
|
||||
@ -90,6 +90,7 @@ export const RepositoriesPopover: React.FunctionComponent<React.PropsWithChildre
|
||||
return data.repositories
|
||||
},
|
||||
options: {
|
||||
pageSize: BATCH_COUNT,
|
||||
fetchPolicy: 'cache-first',
|
||||
},
|
||||
})
|
||||
@ -97,7 +98,6 @@ export const RepositoriesPopover: React.FunctionComponent<React.PropsWithChildre
|
||||
const summary = connection && (
|
||||
<ConnectionSummary
|
||||
connection={connection}
|
||||
first={BATCH_COUNT}
|
||||
noun="repository"
|
||||
pluralNoun="repositories"
|
||||
hasNextPage={hasNextPage}
|
||||
|
||||
@ -198,7 +198,6 @@ describe('RevisionsPopover', () => {
|
||||
await waitForNextApolloResponse()
|
||||
|
||||
expect(within(commitsTab).getAllByRole('link')).toHaveLength(2)
|
||||
expect(within(commitsTab).getByTestId('summary')).toHaveTextContent('2 commits matching some query')
|
||||
})
|
||||
|
||||
describe('Against a speculative revision', () => {
|
||||
|
||||
@ -144,7 +144,6 @@ export const RevisionsPopoverCommits: React.FunctionComponent<
|
||||
query: REPOSITORY_GIT_COMMIT,
|
||||
variables: {
|
||||
query,
|
||||
first: BATCH_COUNT,
|
||||
repo,
|
||||
revision: currentRev || defaultBranch,
|
||||
},
|
||||
@ -175,13 +174,13 @@ export const RevisionsPopoverCommits: React.FunctionComponent<
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'cache-first',
|
||||
pageSize: BATCH_COUNT,
|
||||
},
|
||||
})
|
||||
|
||||
const summary = response.connection && (
|
||||
<ConnectionSummary
|
||||
connection={response.connection}
|
||||
first={BATCH_COUNT}
|
||||
noun={noun}
|
||||
pluralNoun={pluralNoun}
|
||||
hasNextPage={response.hasNextPage}
|
||||
|
||||
@ -11,7 +11,7 @@ import { useDebounce } from '@sourcegraph/wildcard'
|
||||
import { useShowMorePagination } from '../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import { ConnectionSummary } from '../../components/FilteredConnection/ui'
|
||||
import type { GitRefFields, RepositoryGitRefsResult, RepositoryGitRefsVariables } from '../../graphql-operations'
|
||||
import { type GitReferenceNodeProps, REPOSITORY_GIT_REFS } from '../GitReference'
|
||||
import { REPOSITORY_GIT_REFS, type GitReferenceNodeProps } from '../GitReference'
|
||||
|
||||
import { ConnectionPopoverGitReferenceNode } from './components'
|
||||
import { RevisionsPopoverTab } from './RevisionsPopoverTab'
|
||||
@ -150,7 +150,6 @@ export const RevisionsPopoverReferences: React.FunctionComponent<
|
||||
query: REPOSITORY_GIT_REFS,
|
||||
variables: {
|
||||
query,
|
||||
first: BATCH_COUNT,
|
||||
repo,
|
||||
type,
|
||||
withBehindAhead: false,
|
||||
@ -163,6 +162,7 @@ export const RevisionsPopoverReferences: React.FunctionComponent<
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'cache-first',
|
||||
pageSize: BATCH_COUNT,
|
||||
},
|
||||
})
|
||||
|
||||
@ -170,7 +170,6 @@ export const RevisionsPopoverReferences: React.FunctionComponent<
|
||||
<ConnectionSummary
|
||||
emptyElement={showSpeculativeResults ? <></> : undefined}
|
||||
connection={response.connection}
|
||||
first={BATCH_COUNT}
|
||||
noun={noun}
|
||||
pluralNoun={pluralNoun}
|
||||
hasNextPage={response.hasNextPage}
|
||||
|
||||
@ -39,7 +39,7 @@ function fetchBlobCacheKey(options: FetchBlobOptions): string {
|
||||
|
||||
return `${makeRepoGitURI(
|
||||
options
|
||||
)}?disableTimeout=${disableTimeout}&=${format}&snap=${scipSnapshot}&visible=${visibleIndexID}`
|
||||
)}?disableTimeout=${disableTimeout}&=${format}&snap=${scipSnapshot}&first=${visibleIndexID}`
|
||||
}
|
||||
|
||||
interface FetchBlobOptions {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { type FC, useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, type FC } from 'react'
|
||||
|
||||
import { capitalize } from 'lodash'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
@ -6,13 +6,14 @@ import { useLocation } from 'react-router-dom'
|
||||
import { basename, pluralize } from '@sourcegraph/common'
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
import { displayRepoName } from '@sourcegraph/shared/src/components/RepoLink'
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
|
||||
import type { RevisionSpec } from '@sourcegraph/shared/src/util/url'
|
||||
import { Code, Heading, ErrorAlert } from '@sourcegraph/wildcard'
|
||||
import { Code, ErrorAlert, Heading } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { BreadcrumbSetters } from '../../components/Breadcrumbs'
|
||||
import { useUrlSearchParamsForConnectionState } from '../../components/FilteredConnection/hooks/connectionState'
|
||||
import { useShowMorePagination } from '../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
@ -24,11 +25,11 @@ import {
|
||||
} from '../../components/FilteredConnection/ui'
|
||||
import { PageTitle } from '../../components/PageTitle'
|
||||
import {
|
||||
RepositoryType,
|
||||
type GitCommitFields,
|
||||
type RepositoryFields,
|
||||
type RepositoryGitCommitsResult,
|
||||
type RepositoryGitCommitsVariables,
|
||||
RepositoryType,
|
||||
} from '../../graphql-operations'
|
||||
import { parseBrowserRepoURL } from '../../util/url'
|
||||
import { externalLinkFieldsFragment } from '../backend'
|
||||
@ -97,8 +98,6 @@ export const gitCommitFragment = gql`
|
||||
${externalLinkFieldsFragment}
|
||||
`
|
||||
|
||||
const REPOSITORY_GIT_COMMITS_PER_PAGE = 20
|
||||
|
||||
export const REPOSITORY_GIT_COMMITS_QUERY = gql`
|
||||
query RepositoryGitCommits($repo: ID!, $revspec: String!, $first: Int, $afterCursor: String, $filePath: String) {
|
||||
node(id: $repo) {
|
||||
@ -137,6 +136,7 @@ export const RepositoryCommitsPage: FC<RepositoryCommitsPageProps> = props => {
|
||||
|
||||
let sourceType = RepositoryType.GIT_REPOSITORY
|
||||
|
||||
const connectionState = useUrlSearchParamsForConnectionState([])
|
||||
const { connection, error, loading, hasNextPage, fetchMore } = useShowMorePagination<
|
||||
RepositoryGitCommitsResult,
|
||||
RepositoryGitCommitsVariables,
|
||||
@ -147,8 +147,6 @@ export const RepositoryCommitsPage: FC<RepositoryCommitsPageProps> = props => {
|
||||
repo: repo.id,
|
||||
revspec: props.revision,
|
||||
filePath: filePath ?? null,
|
||||
first: REPOSITORY_GIT_COMMITS_PER_PAGE,
|
||||
afterCursor: null,
|
||||
},
|
||||
getConnection: result => {
|
||||
const { node } = dataOrThrowErrors(result)
|
||||
@ -171,6 +169,7 @@ export const RepositoryCommitsPage: FC<RepositoryCommitsPageProps> = props => {
|
||||
useAlternateAfterCursor: true,
|
||||
errorPolicy: 'all',
|
||||
},
|
||||
state: connectionState,
|
||||
})
|
||||
|
||||
const getPageTitle = (): string => {
|
||||
@ -267,7 +266,6 @@ export const RepositoryCommitsPage: FC<RepositoryCommitsPageProps> = props => {
|
||||
<SummaryContainer centered={true}>
|
||||
<ConnectionSummary
|
||||
centered={true}
|
||||
first={REPOSITORY_GIT_COMMITS_PER_PAGE}
|
||||
connection={connection}
|
||||
noun={getRefType(sourceType)}
|
||||
pluralNoun={pluralize(getRefType(sourceType), 0)}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import type { NavigateFunction, Location } from 'react-router-dom'
|
||||
import { type Observable, Subject, Subscription } from 'rxjs'
|
||||
import type { Location, NavigateFunction } from 'react-router-dom'
|
||||
import { Subject, Subscription, type Observable } from 'rxjs'
|
||||
import { distinctUntilChanged, map, startWith } from 'rxjs/operators'
|
||||
|
||||
import { createAggregateError } from '@sourcegraph/common'
|
||||
import { gql } from '@sourcegraph/http-client'
|
||||
import { CardHeader, Card } from '@sourcegraph/wildcard'
|
||||
import { Card, CardHeader } from '@sourcegraph/wildcard'
|
||||
|
||||
import { queryGraphQL } from '../../backend/graphql'
|
||||
import { FilteredConnection } from '../../components/FilteredConnection'
|
||||
@ -22,7 +22,7 @@ function queryRepositoryComparisonCommits(args: {
|
||||
repo: Scalars['ID']
|
||||
base: string | null
|
||||
head: string | null
|
||||
first?: number
|
||||
first?: number | null
|
||||
path?: string
|
||||
}): Observable<RepositoryComparisonRepository['comparison']['commits']> {
|
||||
return queryGraphQL<RepositoryComparisonCommitsResult>(
|
||||
@ -130,7 +130,7 @@ export class RepositoryCompareCommitsPage extends React.PureComponent<Props> {
|
||||
}
|
||||
|
||||
private queryCommits = (args: {
|
||||
first?: number
|
||||
first?: number | null
|
||||
}): Observable<RepositoryComparisonRepository['comparison']['commits']> =>
|
||||
queryRepositoryComparisonCommits({
|
||||
...args,
|
||||
|
||||
@ -6,14 +6,14 @@ import { map } from 'rxjs/operators'
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
import type { Scalars } from '@sourcegraph/shared/src/graphql-operations'
|
||||
|
||||
import { fileDiffFields, diffStatFields } from '../../backend/diff'
|
||||
import { diffStatFields, fileDiffFields } from '../../backend/diff'
|
||||
import { requestGraphQL } from '../../backend/graphql'
|
||||
import { FileDiffNode, type FileDiffNodeProps } from '../../components/diff/FileDiffNode'
|
||||
import { type ConnectionQueryArguments, FilteredConnection } from '../../components/FilteredConnection'
|
||||
import { FilteredConnection, type FilteredConnectionQueryArguments } from '../../components/FilteredConnection'
|
||||
import type {
|
||||
FileDiffFields,
|
||||
RepositoryComparisonDiffResult,
|
||||
RepositoryComparisonDiffVariables,
|
||||
FileDiffFields,
|
||||
} from '../../graphql-operations'
|
||||
|
||||
import type { RepositoryCompareAreaPageProps } from './RepositoryCompareArea'
|
||||
@ -95,7 +95,7 @@ interface RepositoryCompareDiffPageProps extends RepositoryCompareAreaPageProps
|
||||
/** A page with the file diffs in the comparison. */
|
||||
export const RepositoryCompareDiffPage: React.FunctionComponent<RepositoryCompareDiffPageProps> = props => {
|
||||
const queryDiffs = useCallback(
|
||||
(args: ConnectionQueryArguments): Observable<RepositoryComparisonDiff['comparison']['fileDiffs']> =>
|
||||
(args: FilteredConnectionQueryArguments): Observable<RepositoryComparisonDiff['comparison']['fileDiffs']> =>
|
||||
queryRepositoryComparisonFileDiffs({
|
||||
first: args.first ?? null,
|
||||
after: args.after ?? null,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user