Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import {useCallback, useEffect, useMemo, useRef} from 'react';

import type {Repository, RepositoryWithSettings} from 'sentry/types/integrations';
import type {Organization} from 'sentry/types/organization';
import {apiOptions} from 'sentry/utils/api/apiOptions';
import getApiUrl from 'sentry/utils/api/getApiUrl';
import useFetchSequentialPages from 'sentry/utils/api/useFetchSequentialPages';
import {encodeSort} from 'sentry/utils/discover/eventView';
import type {Sort} from 'sentry/utils/discover/fields';
import type {ApiQueryKey} from 'sentry/utils/queryClient';
import useOrganization from 'sentry/utils/useOrganization';

interface Props {
query?: Record<string, string>;
}

/**
* @deprecated Use organizationRepositoriesInfiniteOptions instead.
*/
export function useOrganizationRepositories<T extends Repository = Repository>(
{query = {}} = {} as Props
) {
Expand Down Expand Up @@ -59,9 +66,29 @@ export function useOrganizationRepositories<T extends Repository = Repository>(
);
}

// TODO(ryan953): express this in typescript instead of having the extra function
export function useOrganizationRepositoriesWithSettings() {
return useOrganizationRepositories<RepositoryWithSettings>({
query: {expand: 'settings'},
});
export function organizationRepositoriesInfiniteOptions({
organization,
query,
staleTime,
}: {
organization: Organization;
query?: {
cursor?: string;
integration_id?: string;
per_page?: number;
query?: string;
sort?: Sort;
status?: 'active' | 'deleted' | 'unmigratable';
};
staleTime?: number;
}) {
const sortQuery = query?.sort ? encodeSort(query.sort) : undefined;
return apiOptions.asInfinite<RepositoryWithSettings[]>()(
'/organizations/$organizationIdOrSlug/repos/',
{
path: {organizationIdOrSlug: organization.slug},
query: {expand: 'settings', per_page: 100, ...query, sort: sortQuery},
staleTime: staleTime ?? 0,
}
);
}
44 changes: 37 additions & 7 deletions static/app/utils/api/apiFetch.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {QueryFunctionContext} from '@tanstack/react-query';

import type {ApiQueryKey} from 'sentry/utils/queryClient';
import type {ParsedHeader} from 'sentry/utils/parseLinkHeader';
import type {ApiQueryKey, InfiniteApiQueryKey} from 'sentry/utils/queryClient';
import {parseQueryKey, QUERY_API_CLIENT} from 'sentry/utils/queryClient';

export type ApiResponse<TResponseData = unknown> = {
Expand All @@ -13,7 +14,7 @@ export type ApiResponse<TResponseData = unknown> = {
};

export default async function apiFetch<TQueryFnData = unknown>(
context: QueryFunctionContext<ApiQueryKey>
context: QueryFunctionContext<ApiQueryKey, never>
): Promise<ApiResponse<TQueryFnData>> {
const {url, options} = parseQueryKey(context.queryKey);

Expand All @@ -26,13 +27,42 @@ export default async function apiFetch<TQueryFnData = unknown>(
headers: options?.headers,
});

const xhits = response!.getResponseHeader('X-Hits') ?? null;
const xmaxhits = response!.getResponseHeader('X-Max-Hits') ?? null;
const hits = response?.getResponseHeader('X-Hits');
const maxHits = response?.getResponseHeader('X-Max-Hits');
return {
headers: {
Link: response!.getResponseHeader('Link') ?? undefined,
'X-Hits': xhits === null ? undefined : Number(xhits),
'X-Max-Hits': xmaxhits === null ? undefined : Number(xmaxhits),
Link: response?.getResponseHeader('Link') ?? undefined,
'X-Hits': typeof hits === 'string' ? Number(hits) : undefined,
'X-Max-Hits': typeof maxHits === 'string' ? Number(maxHits) : undefined,
},
json: json as TQueryFnData,
};
}

export async function apiFetchInfinite<TQueryFnData = unknown>(
context: QueryFunctionContext<InfiniteApiQueryKey, null | undefined | ParsedHeader>
): Promise<ApiResponse<TQueryFnData>> {
const {url, options} = parseQueryKey(context.queryKey);

const [json, , response] = await QUERY_API_CLIENT.requestPromise(url, {
includeAllArgs: true,
host: options?.host,
method: options?.method ?? 'GET',
data: options?.data,
query: {
...options?.query,
cursor: context.pageParam?.cursor ?? options?.query?.cursor,
},
headers: options?.headers,
});

const hits = response?.getResponseHeader('X-Hits');
const maxHits = response?.getResponseHeader('X-Max-Hits');
return {
headers: {
Link: response?.getResponseHeader('Link') ?? undefined,
'X-Hits': typeof hits === 'string' ? Number(hits) : undefined,
'X-Max-Hits': typeof maxHits === 'string' ? Number(maxHits) : undefined,
},
json: json as TQueryFnData,
};
Expand Down
4 changes: 2 additions & 2 deletions static/app/utils/api/apiOptions.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {expectTypeOf} from 'expect-type';
import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary';

import type {ApiResponse} from 'sentry/utils/api/apiFetch';
import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions';
import {apiOptions} from 'sentry/utils/api/apiOptions';

type Promisable<T> = T | Promise<T>;
type QueryFunctionResult<T> = Promisable<ApiResponse<T>>;
Expand Down Expand Up @@ -99,7 +99,7 @@ describe('apiOptions', () => {
});

const {result} = renderHookWithProviders(() =>
useQuery({...options, select: selectJsonWithHeaders})
useQuery({...options, select: _ => _})
);

await waitFor(() => expect(result.current.isPending).toBe(false));
Expand Down
78 changes: 56 additions & 22 deletions static/app/utils/api/apiOptions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import {queryOptions, skipToken} from '@tanstack/react-query';
import type {SkipToken} from '@tanstack/react-query';

import apiFetch, {type ApiResponse} from 'sentry/utils/api/apiFetch';
import apiFetch, {apiFetchInfinite} from 'sentry/utils/api/apiFetch';
import type {ApiResponse} from 'sentry/utils/api/apiFetch';
import getApiUrl from 'sentry/utils/api/getApiUrl';
import type {ExtractPathParams, OptionalPathParams} from 'sentry/utils/api/getApiUrl';
import type {KnownGetsentryApiUrls} from 'sentry/utils/api/knownGetsentryApiUrls';
import type {KnownSentryApiUrls} from 'sentry/utils/api/knownSentryApiUrls.generated';
import type {ApiQueryKey, QueryKeyEndpointOptions} from 'sentry/utils/queryClient';
import parseLinkHeader from 'sentry/utils/parseLinkHeader';
import {infiniteQueryOptions, queryOptions, skipToken} from 'sentry/utils/queryClient';
import type {
ApiQueryKey,
InfiniteApiQueryKey,
QueryKeyEndpointOptions,
SkipToken,
} from 'sentry/utils/queryClient';

type KnownApiUrls = KnownGetsentryApiUrls | KnownSentryApiUrls;

Expand All @@ -17,14 +22,6 @@ type PathParamOptions<TApiPath extends string> =
? {path?: never}
: {path: Record<ExtractPathParams<TApiPath>, string | number> | SkipToken};

/** @public **/
export const selectJson = <TData>(data: ApiResponse<TData>) => data.json;

/** @public **/
export const selectJsonWithHeaders = <TData>(
data: ApiResponse<TData>
): ApiResponse<TData> => data;

function _apiOptions<
TManualData = never,
TApiPath extends KnownApiUrls = KnownApiUrls,
Expand All @@ -38,14 +35,7 @@ function _apiOptions<
? [Options & {path?: never}]
: [Options & PathParamOptions<TApiPath>]
) {
const url = getApiUrl(
path,
...([
{
path: pathParams,
},
] as OptionalPathParams<TApiPath>)
);
const url = getApiUrl(path, ...([{path: pathParams}] as OptionalPathParams<TApiPath>));

return queryOptions({
queryKey:
Expand All @@ -55,7 +45,43 @@ function _apiOptions<
queryFn: pathParams === skipToken ? skipToken : apiFetch<TActualData>,
enabled: pathParams !== skipToken,
staleTime,
select: selectJson,
select: data => data.json,
});
}

function parsePageParam<TQueryFnData = unknown>(dir: 'previous' | 'next') {
return ({headers}: ApiResponse<TQueryFnData>) => {
const parsed = parseLinkHeader(headers.Link ?? null);
return parsed[dir]?.results ? parsed[dir] : null;
};
}

function _apiOptionsInfinite<
TManualData = never,
TApiPath extends KnownApiUrls = KnownApiUrls,
// todo: infer the actual data type from the ApiMapping
TActualData = TManualData,
>(
path: TApiPath,
...[
{staleTime, path: pathParams, ...options},
]: ExtractPathParams<TApiPath> extends never
? [Options & {path?: never}]
: [Options & PathParamOptions<TApiPath>]
) {
const url = getApiUrl(path, ...([{path: pathParams}] as OptionalPathParams<TApiPath>));

return infiniteQueryOptions({
queryKey:
Object.keys(options).length > 0
? (['infinite', url, options] as InfiniteApiQueryKey)
: (['infinite', url] as InfiniteApiQueryKey),
queryFn: pathParams === skipToken ? skipToken : apiFetchInfinite<TActualData>,
getPreviousPageParam: parsePageParam('previous'),
getNextPageParam: parsePageParam('next'),
initialPageParam: undefined,
enabled: pathParams !== skipToken,
staleTime,
});
}

Expand All @@ -67,4 +93,12 @@ export const apiOptions = {
options: Options & PathParamOptions<TApiPath>
) =>
_apiOptions<TManualData, TApiPath>(path, options as never),

asInfinite:
<TManualData>() =>
<TApiPath extends KnownApiUrls = KnownApiUrls>(
path: TApiPath,
options: Options & PathParamOptions<TApiPath>
) =>
_apiOptionsInfinite<TManualData, TApiPath>(path, options as never),
};
Loading
Loading