diff --git a/static/app/views/onboarding/components/scmRepoSelector.tsx b/static/app/views/onboarding/components/scmRepoSelector.tsx index 5e45d17bf5e350..7bc20401ab52a3 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 {useMemo, useRef} 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 {makeVirtualizedMenuList} from './scmVirtualizedMenuList'; import {useScmRepoSearch} from './useScmRepoSearch'; import {useScmRepoSelection} from './useScmRepoSelection'; @@ -24,7 +25,10 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) { reposByIdentifier, dropdownItems, isFetching, + isFetchingNextPage, isError, + hasNextPage, + fetchNextPage, debouncedSearch, setSearch, } = useScmRepoSearch(integration.id, selectedRepository); @@ -35,6 +39,21 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) { reposByIdentifier, }); + const onNearBottomRef = useRef<(() => void) | undefined>(undefined); + onNearBottomRef.current = () => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }; + + const components = useMemo( + () => ({ + Control: ScmSearchControl, + MenuList: makeVirtualizedMenuList(onNearBottomRef), + }), + [] + ); + // Prepend the selected repo so the Select can always resolve and display // it, even when search results no longer include it. const options = useMemo(() => { @@ -82,7 +101,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 available'); } return ( @@ -96,14 +115,12 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) { setSearch(value); } }} - // 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={components} /> ); } diff --git a/static/app/views/onboarding/components/scmVirtualizedMenuList.tsx b/static/app/views/onboarding/components/scmVirtualizedMenuList.tsx index 1c9fb3d57c5862..f39c3ac658e398 100644 --- a/static/app/views/onboarding/components/scmVirtualizedMenuList.tsx +++ b/static/app/views/onboarding/components/scmVirtualizedMenuList.tsx @@ -4,18 +4,25 @@ * causes ~1s lag with 130+ platform options containing PlatformIcon SVGs. * Virtualizing limits mounted components to the visible set. * + * Supports prefetching via `onNearBottomRef`: when the user scrolls + * within PREFETCH_THRESHOLD px of the bottom, the callback fires — + * well before react-select's built-in onMenuScrollToBottom. + * * Stopgap until a Combobox scraps component replaces this * (see #discuss-design-engineering). * - * Usage: */ -import {type Ref, useRef} from 'react'; +import {type MutableRefObject, type Ref, useCallback, useRef} from 'react'; import {mergeRefs} from '@react-aria/utils'; import {useVirtualizer} from '@tanstack/react-virtual'; const OPTION_HEIGHT = 36; const MAX_MENU_HEIGHT = 300; +const PREFETCH_THRESHOLD = 1000; interface ScmVirtualizedMenuListProps { children: React.ReactNode; @@ -25,17 +32,43 @@ interface ScmVirtualizedMenuListProps { optionHeight?: number; } +/** + * Creates a VirtualizedMenuList component bound to an optional near-bottom + * callback ref. This avoids the need to pass custom props through react-select. + */ +export function makeVirtualizedMenuList( + onNearBottomRef?: MutableRefObject<(() => void) | undefined> +) { + return function VirtualizedMenuList(props: ScmVirtualizedMenuListProps) { + return ; + }; +} + export function ScmVirtualizedMenuList({ children, maxHeight = MAX_MENU_HEIGHT, optionHeight = OPTION_HEIGHT, innerRef, innerProps, -}: ScmVirtualizedMenuListProps) { + onNearBottomRef, +}: ScmVirtualizedMenuListProps & { + onNearBottomRef?: MutableRefObject<(() => void) | undefined>; +}) { const items = Array.isArray(children) ? children : []; const scrollRef = useRef(null); const combinedRef = mergeRefs(scrollRef, innerRef ?? null); + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el || !onNearBottomRef?.current) { + return; + } + const remaining = el.scrollHeight - el.scrollTop - el.clientHeight; + if (remaining < PREFETCH_THRESHOLD) { + onNearBottomRef.current(); + } + }, [onNearBottomRef]); + const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => scrollRef.current, @@ -56,7 +89,12 @@ export function ScmVirtualizedMenuList({ } return ( -
+
{virtualItems.map(virtualRow => (
({ + identifier: `${prefix}-${i}`, + name: `${prefix.split('/')[1]}-${i}`, + defaultBranch: 'main', + isInstalled: false, + })); +} + +describe('useScmRepoSearch', () => { + const organization = OrganizationFixture(); + const reposUrl = `/organizations/${organization.slug}/integrations/1/repos/`; + + afterEach(() => { + MockApiClient.clearMockResponses(); + }); + + it('fires browse request on mount without requiring search', async () => { + const browseRequest = MockApiClient.addMockResponse({ + url: reposUrl, + body: {repos: makeRepos(3), searchable: true}, + match: [MockApiClient.matchQuery({accessibleOnly: true, paginate: true})], + }); + + const {result} = renderHookWithProviders(() => useScmRepoSearch('1'), { + organization, + }); + + await waitFor(() => expect(result.current.dropdownItems).toHaveLength(3)); + expect(browseRequest).toHaveBeenCalled(); + expect(result.current.dropdownItems[0]!.value).toBe('org/repo-0'); + }); + + it('uses server-side search when user types', async () => { + MockApiClient.addMockResponse({ + url: reposUrl, + body: {repos: makeRepos(5), searchable: true}, + match: [MockApiClient.matchQuery({accessibleOnly: true, paginate: true})], + }); + + const searchRequest = MockApiClient.addMockResponse({ + url: reposUrl, + body: { + repos: [{identifier: 'org/match', name: 'match', isInstalled: false}], + searchable: true, + }, + match: [MockApiClient.matchQuery({search: 'match', accessibleOnly: true})], + }); + + const {result} = renderHookWithProviders(() => useScmRepoSearch('1'), { + organization, + }); + + // Wait for browse results first + await waitFor(() => expect(result.current.dropdownItems).toHaveLength(5)); + + // Type a search query + act(() => { + result.current.setSearch('match'); + }); + + await waitFor(() => expect(searchRequest).toHaveBeenCalled()); + await waitFor(() => expect(result.current.dropdownItems).toHaveLength(1)); + expect(result.current.dropdownItems[0]!.value).toBe('org/match'); + }); + + it('returns to browse results when search is cleared', async () => { + MockApiClient.addMockResponse({ + url: reposUrl, + body: {repos: makeRepos(3), searchable: true}, + match: [MockApiClient.matchQuery({accessibleOnly: true, paginate: true})], + }); + + MockApiClient.addMockResponse({ + url: reposUrl, + body: { + repos: [{identifier: 'org/x', name: 'x', isInstalled: false}], + searchable: true, + }, + match: [MockApiClient.matchQuery({search: 'x', accessibleOnly: true})], + }); + + const {result} = renderHookWithProviders(() => useScmRepoSearch('1'), { + organization, + }); + + await waitFor(() => expect(result.current.dropdownItems).toHaveLength(3)); + + // Search + act(() => { + result.current.setSearch('x'); + }); + await waitFor(() => expect(result.current.dropdownItems).toHaveLength(1)); + + // Clear search -- should return to browse results + act(() => { + result.current.setSearch(''); + }); + await waitFor(() => expect(result.current.dropdownItems).toHaveLength(3)); + }); + + it('marks the selected repo as disabled in dropdown items', async () => { + MockApiClient.addMockResponse({ + url: reposUrl, + body: { + repos: [ + {identifier: 'org/selected', name: 'selected', isInstalled: false}, + {identifier: 'org/other', name: 'other', isInstalled: false}, + ], + searchable: true, + }, + match: [MockApiClient.matchQuery({accessibleOnly: true, paginate: true})], + }); + + const selectedRepo = RepositoryFixture({ + name: 'org/selected', + externalSlug: 'org/selected', + }); + + const {result} = renderHookWithProviders(() => useScmRepoSearch('1', selectedRepo), { + organization, + }); + + await waitFor(() => expect(result.current.dropdownItems).toHaveLength(2)); + + const selectedItem = result.current.dropdownItems.find( + item => item.value === 'org/selected' + ); + const otherItem = result.current.dropdownItems.find( + item => item.value === 'org/other' + ); + expect(selectedItem!.disabled).toBe(true); + expect(otherItem!.disabled).toBe(false); + }); +}); diff --git a/static/app/views/onboarding/components/useScmRepoSearch.ts b/static/app/views/onboarding/components/useScmRepoSearch.ts index 378c20be131467..721d7cf24badb1 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'; @@ -15,17 +15,32 @@ export function useScmRepoSearch(integrationId: string, selectedRepo?: Repositor const [search, setSearch] = useState(''); const debouncedSearch = useDebouncedValue(search, 200); - const searchQuery = useQuery({ + const reposUrl = getApiUrl( + `/organizations/$organizationIdOrSlug/integrations/$integrationId/repos/`, + { + path: { + organizationIdOrSlug: organization.slug, + integrationId, + }, + } + ); + + // Browse: paginated, fires on mount. Additional pages are fetched on + // demand when the user scrolls near the bottom of the dropdown. + const browseResult = useInfiniteApiQuery({ queryKey: [ - getApiUrl( - `/organizations/$organizationIdOrSlug/integrations/$integrationId/repos/`, - { - path: { - organizationIdOrSlug: organization.slug, - integrationId, - }, - } - ), + {infinite: true, version: 'v1' as const}, + reposUrl, + {method: 'GET', query: {accessibleOnly: true, paginate: true, per_page: 50}}, + ], + staleTime: 20_000, + }); + + // Search: non-paginated, fires when user types. Uses server-side filtering + // with accessibleOnly=true to guarantee accurate results. + const searchResult = useQuery({ + queryKey: [ + reposUrl, {method: 'GET', query: {search: debouncedSearch, accessibleOnly: true}}, ] as const, queryFn: async context => { @@ -33,15 +48,24 @@ export function useScmRepoSearch(integrationId: string, selectedRepo?: Repositor }, retry: 0, staleTime: 20_000, - placeholderData: previousData => (debouncedSearch ? previousData : undefined), + placeholderData: previousData => previousData, enabled: !!debouncedSearch, }); + const isSearching = !!debouncedSearch; + + const repos = useMemo(() => { + if (isSearching) { + return searchResult.data?.[0]?.repos ?? []; + } + return browseResult.data?.pages.flatMap(([data]) => data?.repos ?? []) ?? []; + }, [isSearching, searchResult.data, browseResult.data]); + const selectedRepoSlug = selectedRepo?.externalSlug; const {reposByIdentifier, dropdownItems} = useMemo( () => - (searchQuery.data?.[0]?.repos ?? []).reduce<{ + repos.reduce<{ dropdownItems: Array<{ disabled: boolean; label: string; @@ -63,14 +87,17 @@ export function useScmRepoSearch(integrationId: string, selectedRepo?: Repositor dropdownItems: [], } ), - [searchQuery.data, selectedRepoSlug] + [repos, selectedRepoSlug] ); return { reposByIdentifier, dropdownItems, - isFetching: searchQuery.isFetching, - isError: searchQuery.isError, + isFetching: isSearching ? searchResult.isFetching : browseResult.isFetching, + isFetchingNextPage: browseResult.isFetchingNextPage, + isError: isSearching ? searchResult.isError : browseResult.isError, + hasNextPage: browseResult.hasNextPage, + fetchNextPage: browseResult.fetchNextPage, debouncedSearch, setSearch, };