Skip to content

Commit 253e1ed

Browse files
JonasBaclaude
authored andcommitted
feat(page-filters): Use fzf for project search in ProjectPageFilter (#108725)
Use fzf for project search in ProjectPageFilter The project dropdown previously used a simple case-insensitive substring match on the project slug. This replaces it with the fzf v1 algorithm (already present in the codebase at `sentry/utils/profiling/fzf/fzf`) via the `searchMatcher` prop introduced in the base branch. With fzf, users can find projects using fuzzy/subsequence queries — e.g. typing `frd` will match `frontend` — and results are ranked by match quality so the most relevant projects float to the top. The matcher is defined as a module-level function (no `useCallback` needed) since it has no dependency on component state or props. Stacked on top of: master...jb/compactselect/search-result --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d2925e commit 253e1ed

File tree

2 files changed

+63
-1
lines changed

2 files changed

+63
-1
lines changed

static/app/components/core/compactSelect/compactSelect.spec.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,48 @@ describe('CompactSelect', () => {
749749
expect(screen.getAllByRole('option')).toHaveLength(1);
750750
});
751751

752+
it('uses custom searchMatcher with sections', async () => {
753+
render(
754+
<CompactSelect
755+
value={undefined}
756+
onChange={jest.fn()}
757+
searchable
758+
searchPlaceholder="Search here…"
759+
searchMatcher={(option, search) => ({
760+
score: String(option.value).endsWith(search) ? 1 : 0,
761+
})}
762+
options={[
763+
{
764+
key: 'section-1',
765+
label: 'Section 1',
766+
options: [
767+
{value: 'opt_one', label: 'Option One'},
768+
{value: 'opt_two', label: 'Option Two'},
769+
],
770+
},
771+
{
772+
key: 'section-2',
773+
label: 'Section 2',
774+
options: [
775+
{value: 'opt_three', label: 'Option Three'},
776+
{value: 'opt_four', label: 'Option Four'},
777+
],
778+
},
779+
]}
780+
/>
781+
);
782+
783+
await userEvent.click(screen.getByRole('button'));
784+
await userEvent.click(screen.getByPlaceholderText('Search here…'));
785+
786+
// 'e' matches opt_one (section 1) and opt_three (section 2) by value suffix
787+
await userEvent.keyboard('e');
788+
expect(screen.getByRole('option', {name: 'Option One'})).toBeInTheDocument();
789+
expect(screen.queryByRole('option', {name: 'Option Two'})).not.toBeInTheDocument();
790+
expect(screen.getByRole('option', {name: 'Option Three'})).toBeInTheDocument();
791+
expect(screen.queryByRole('option', {name: 'Option Four'})).not.toBeInTheDocument();
792+
});
793+
752794
it('can limit the number of options', async () => {
753795
render(
754796
<CompactSelect

static/app/components/pageFilters/project/projectPageFilter.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import sortBy from 'lodash/sortBy';
77

88
import {Alert} from '@sentry/scraps/alert';
99
import {LinkButton} from '@sentry/scraps/button';
10-
import type {SelectOption, SelectOptionOrSection} from '@sentry/scraps/compactSelect';
10+
import type {
11+
SelectKey,
12+
SelectOption,
13+
SelectOptionOrSection,
14+
SelectOptionWithKey,
15+
} from '@sentry/scraps/compactSelect';
1116
import {InfoTip} from '@sentry/scraps/info';
1217
import {Flex, Stack} from '@sentry/scraps/layout';
1318
import {Text} from '@sentry/scraps/text';
@@ -32,6 +37,7 @@ import {t, tct} from 'sentry/locale';
3237
import type {Project} from 'sentry/types/project';
3338
import {trackAnalytics} from 'sentry/utils/analytics';
3439
import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
40+
import {fzf} from 'sentry/utils/profiling/fzf/fzf';
3541
import useOrganization from 'sentry/utils/useOrganization';
3642
import useProjects from 'sentry/utils/useProjects';
3743
import useRouter from 'sentry/utils/useRouter';
@@ -76,6 +82,19 @@ export interface ProjectPageFilterProps extends Partial<
7682
storageNamespace?: string;
7783
}
7884

85+
/**
86+
* fzf-based search matcher for the project dropdown. Runs the fzf v1 algorithm
87+
* against the option's textValue (project slug) so that fuzzy/subsequence matches
88+
* are ranked by score rather than relying on plain substring inclusion.
89+
*/
90+
function projectSearchMatcher(option: SelectOptionWithKey<SelectKey>, search: string) {
91+
const text = option.textValue ?? (typeof option.label === 'string' ? option.label : '');
92+
if (!text) {
93+
return {score: 0};
94+
}
95+
return fzf(text, search.toLowerCase(), false);
96+
}
97+
7998
/**
8099
* Maximum number of projects that can be selected at a time (due to server limits). This
81100
* does not apply to special values like "My Projects" and "All Projects".
@@ -431,6 +450,7 @@ export function ProjectPageFilter({
431450
return (
432451
<HybridFilter
433452
ref={hybridFilterRef}
453+
searchMatcher={projectSearchMatcher}
434454
{...selectProps}
435455
stagedSelect={stagedSelect}
436456
searchable

0 commit comments

Comments
 (0)