diff --git a/static/app/views/onboarding/components/scmRepoSelector.tsx b/static/app/views/onboarding/components/scmRepoSelector.tsx index 5e45d17bf5e350..c0267855bf0bd4 100644 --- a/static/app/views/onboarding/components/scmRepoSelector.tsx +++ b/static/app/views/onboarding/components/scmRepoSelector.tsx @@ -1,4 +1,4 @@ -import {useMemo} from 'react'; +import {useCallback, useMemo} from 'react'; import {Select} from '@sentry/scraps/select'; @@ -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'; @@ -27,6 +28,9 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) { isError, debouncedSearch, setSearch, + hasNextPage, + fetchNextPage, + isFetchingNextPage, } = useScmRepoSearch(integration.id, selectedRepository); const {busy, handleSelect, handleRemove} = useScmRepoSelection({ @@ -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.'); @@ -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 ( @@ -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}} /> ); } diff --git a/static/app/views/onboarding/components/useScmRepoSearch.ts b/static/app/views/onboarding/components/useScmRepoSearch.ts index 9d47285e42557a..777c552f2bf6fa 100644 --- a/static/app/views/onboarding/components/useScmRepoSearch.ts +++ b/static/app/views/onboarding/components/useScmRepoSearch.ts @@ -2,7 +2,7 @@ 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'; @@ -10,22 +10,33 @@ 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({ + 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 => { @@ -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; @@ -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, }; }