Skip to content
Closed
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
29 changes: 23 additions & 6 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 {useMemo, useRef} 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 {makeVirtualizedMenuList} from './scmVirtualizedMenuList';
import {useScmRepoSearch} from './useScmRepoSearch';
import {useScmRepoSelection} from './useScmRepoSelection';

Expand All @@ -24,7 +25,10 @@ export function ScmRepoSelector({integration}: ScmRepoSelectorProps) {
reposByIdentifier,
dropdownItems,
isFetching,
isFetchingNextPage,
isError,
hasNextPage,
fetchNextPage,
debouncedSearch,
setSearch,
} = useScmRepoSearch(integration.id, selectedRepository);
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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 (
Expand All @@ -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}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Select components={{MenuList: ScmVirtualizedMenuList}} />
* Usage:
* const nearBottomRef = useRef(() => fetchNextPage());
* <Select components={{MenuList: makeVirtualizedMenuList(nearBottomRef)}} />
*/

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;
Expand All @@ -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 <ScmVirtualizedMenuList {...props} onNearBottomRef={onNearBottomRef} />;
};
}

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<HTMLDivElement>(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,
Expand All @@ -56,7 +89,12 @@ export function ScmVirtualizedMenuList({
}

return (
<div ref={combinedRef} {...innerProps} style={{maxHeight, overflowY: 'auto'}}>
<div
ref={combinedRef}
{...innerProps}
onScroll={handleScroll}
style={{maxHeight, overflowY: 'auto'}}
>
<div style={{height: virtualizer.getTotalSize(), position: 'relative'}}>
{virtualItems.map(virtualRow => (
<div
Expand Down
142 changes: 142 additions & 0 deletions static/app/views/onboarding/components/useScmRepoSearch.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {OrganizationFixture} from 'sentry-fixture/organization';
import {RepositoryFixture} from 'sentry-fixture/repository';

import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary';

import {useScmRepoSearch} from './useScmRepoSearch';

function makeRepos(count: number, prefix = 'org/repo') {
return Array.from({length: count}, (_, i) => ({
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);
});
});
Loading
Loading