Skip to content
Draft
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
19 changes: 15 additions & 4 deletions static/app/views/onboarding/components/scmRepoSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {useMemo} from 'react';
import {useCallback, useMemo} from 'react';

import {Select} from '@sentry/scraps/select';

Expand All @@ -9,6 +9,7 @@ import {trackAnalytics} from 'sentry/utils/analytics';
import {useOrganization} from 'sentry/utils/useOrganization';

import {ScmSearchControl} from './scmSearchControl';
import {ScmVirtualizedMenuList} from './scmVirtualizedMenuList';
import {useScmRepoSearch} from './useScmRepoSearch';
import {useScmRepoSelection} from './useScmRepoSelection';

Expand All @@ -27,6 +28,9 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) {
isError,
debouncedSearch,
setSearch,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useScmRepoSearch(integration.id, selectedRepository);

const {busy, handleSelect, handleRemove} = useScmRepoSelection({
Expand Down Expand Up @@ -73,6 +77,12 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) {
}
}

const handleMenuScrollToBottom = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);

function noOptionsMessage() {
if (isError) {
return t('Failed to search repositories. Please try again.');
Expand All @@ -82,7 +92,7 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) {
'No repositories found. Check your installation permissions to ensure your integration has access.'
);
}
return t('Type to search repositories');
return t('No repositories found');
}

return (
Expand All @@ -96,14 +106,15 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) {
setSearch(value);
}
}}
onMenuScrollToBottom={handleMenuScrollToBottom}
// Disable client-side filtering; search is handled server-side.
filterOption={() => true}
noOptionsMessage={noOptionsMessage}
isLoading={isFetching}
isLoading={isFetching || isFetchingNextPage}
isDisabled={busy}
clearable
searchable
components={{Control: ScmSearchControl}}
components={{Control: ScmSearchControl, MenuList: ScmVirtualizedMenuList}}
/>
);
}
58 changes: 44 additions & 14 deletions static/app/views/onboarding/components/useScmRepoSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,41 @@ import {useMemo, useState} from 'react';

import type {IntegrationRepository, Repository} from 'sentry/types/integrations';
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
import {fetchDataQuery, useQuery} from 'sentry/utils/queryClient';
import {fetchDataQuery, useInfiniteApiQuery, useQuery} from 'sentry/utils/queryClient';
import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
import {useOrganization} from 'sentry/utils/useOrganization';

interface ScmRepoSearchResult {
repos: IntegrationRepository[];
}

const PER_PAGE = 100;

export function useScmRepoSearch(integrationId: string, selectedRepo?: Repository) {
const organization = useOrganization();
const [search, setSearch] = useState('');
const debouncedSearch = useDebouncedValue(search, 200);

const reposUrl = getApiUrl(
'/organizations/$organizationIdOrSlug/integrations/$integrationId/repos/',
{
path: {
organizationIdOrSlug: organization.slug,
integrationId,
},
}
);

// Browse: paginated fetch that fires on mount, pre-populates the dropdown
const browseQuery = useInfiniteApiQuery<ScmRepoSearchResult>({
queryKey: [{infinite: true, version: 'v1'}, reposUrl, {query: {per_page: PER_PAGE}}],
staleTime: 30_000,
});

// Search: fires when user types, returns full filtered result set
const searchQuery = useQuery({
queryKey: [
getApiUrl(
'/organizations/$organizationIdOrSlug/integrations/$integrationId/repos/',
{
path: {
organizationIdOrSlug: organization.slug,
integrationId,
},
}
),
reposUrl,
{method: 'GET', query: {search: debouncedSearch, accessibleOnly: true}},
] as const,
queryFn: async context => {
Expand All @@ -37,11 +48,26 @@ export function useScmRepoSearch(integrationId: string, selectedRepo?: Repositor
enabled: !!debouncedSearch,
});

const isSearching = !!debouncedSearch;

// Flatten paginated browse results into a single list
const browseRepos = useMemo(
() => browseQuery.data?.pages.flatMap(page => page[0].repos) ?? [],
[browseQuery.data]
);

const searchRepos = useMemo(
() => searchQuery.data?.[0]?.repos ?? [],
[searchQuery.data]
);

const activeRepos = isSearching ? searchRepos : browseRepos;

const selectedRepoSlug = selectedRepo?.externalSlug;

const {reposByIdentifier, dropdownItems} = useMemo(
() =>
(searchQuery.data?.[0]?.repos ?? []).reduce<{
activeRepos.reduce<{
dropdownItems: Array<{
disabled: boolean;
label: string;
Expand All @@ -63,15 +89,19 @@ export function useScmRepoSearch(integrationId: string, selectedRepo?: Repositor
dropdownItems: [],
}
),
[searchQuery.data, selectedRepoSlug]
[activeRepos, selectedRepoSlug]
);

return {
reposByIdentifier,
dropdownItems,
isFetching: searchQuery.isFetching,
isError: searchQuery.isError,
isFetching: isSearching ? searchQuery.isFetching : browseQuery.isFetching,
isError: isSearching ? searchQuery.isError : browseQuery.isError,
debouncedSearch,
setSearch,
// Infinite scroll support
hasNextPage: !isSearching && (browseQuery.hasNextPage ?? false),
fetchNextPage: browseQuery.fetchNextPage,
isFetchingNextPage: browseQuery.isFetchingNextPage,
};
}
Loading