Skip to content

Commit d5ddcfd

Browse files
JonasBaclaude
authored andcommitted
feat(core-ui): Allow searchMatcher to return a score for result ordering (#108719)
Stacked on #108714. Extends the `searchMatcher` prop introduced in the base PR so it can optionally return a `SearchMatchResult` object in addition to a plain `boolean`. ```ts interface SearchMatchResult { score: number; } ``` When a matcher returns `SearchMatchResult`, the option is shown and its `score` is used to sort matching options — higher scores appear first. Returning `false` still hides the option. Returning `true` (the existing behaviour) shows it with no ordering influence, so the change is fully additive. --- **Update**: `searchMatcher` now always returns `SearchMatchResult` — the `boolean` return path is removed. A score > 0 means the option matches; score <= 0 hides it. The default implementation returns `{score: 1}` for a substring match and `{score: 0}` otherwise. Sorting is only triggered when a custom `searchMatcher` is provided, so the default path pays no extra cost. Sorting is scoped: within each section for sectioned lists, and globally for flat lists. Options with equal scores maintain their original relative order (stable sort). The `SearchMatchResult` type and the new `getSortedItems` utility are exported from the package for callers building custom composite selects. --------- Co-authored-by: Claude <[email protected]>
1 parent 8909a4c commit d5ddcfd

File tree

7 files changed

+284
-28
lines changed

7 files changed

+284
-28
lines changed

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

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,9 @@ describe('CompactSelect', () => {
518518
<CompactSelect
519519
searchable
520520
searchPlaceholder="Search here…"
521-
searchMatcher={(option, search) => String(option.value).endsWith(search)}
521+
searchMatcher={(option, search) => ({
522+
score: String(option.value).endsWith(search) ? 1 : 0,
523+
})}
522524
options={[
523525
{value: 'opt_one', label: 'Option One'},
524526
{value: 'opt_two', label: 'Option Two'},
@@ -537,6 +539,33 @@ describe('CompactSelect', () => {
537539
expect(screen.queryByRole('option', {name: 'Option Two'})).not.toBeInTheDocument();
538540
});
539541

542+
it('does not call searchMatcher when search is empty, showing all options', async () => {
543+
// A matcher that returns score 0 for any empty query would hide all options if
544+
// called during the initial render (before the user types anything).
545+
render(
546+
<CompactSelect
547+
searchable
548+
searchPlaceholder="Search here…"
549+
searchMatcher={(option, search) => ({
550+
// Return 0 for empty search — real-world matchers may do this
551+
score: search === '' ? 0 : String(option.value).includes(search) ? 1 : 0,
552+
})}
553+
options={[
554+
{value: 'opt_one', label: 'Option One'},
555+
{value: 'opt_two', label: 'Option Two'},
556+
]}
557+
value={undefined}
558+
onChange={jest.fn()}
559+
/>
560+
);
561+
562+
await userEvent.click(screen.getByRole('button'));
563+
564+
// All options should be visible even though the matcher returns 0 for ''
565+
expect(screen.getByRole('option', {name: 'Option One'})).toBeInTheDocument();
566+
expect(screen.getByRole('option', {name: 'Option Two'})).toBeInTheDocument();
567+
});
568+
540569
it('uses string.includes for default search matching', async () => {
541570
render(
542571
<CompactSelect
@@ -560,6 +589,123 @@ describe('CompactSelect', () => {
560589
expect(screen.queryByRole('option', {name: 'Option Two'})).not.toBeInTheDocument();
561590
});
562591

592+
it('sorts options by score when searchMatcher returns SearchMatchResult', async () => {
593+
// Assign scores so the natural order (One, Two, Three) is reversed: Three > Two > One
594+
const scores: Record<string, number> = {opt_one: 1, opt_two: 2, opt_three: 3};
595+
596+
render(
597+
<CompactSelect
598+
searchable
599+
searchPlaceholder="Search here…"
600+
searchMatcher={option => ({score: scores[String(option.value)] ?? 0})}
601+
options={[
602+
{value: 'opt_one', label: 'Option One'},
603+
{value: 'opt_two', label: 'Option Two'},
604+
{value: 'opt_three', label: 'Option Three'},
605+
]}
606+
value={undefined}
607+
onChange={jest.fn()}
608+
/>
609+
);
610+
611+
await userEvent.click(screen.getByRole('button'));
612+
await userEvent.click(screen.getByPlaceholderText('Search here…'));
613+
await userEvent.keyboard('opt');
614+
615+
const options = screen.getAllByRole('option');
616+
expect(options[0]).toHaveTextContent('Option Three'); // score 3
617+
expect(options[1]).toHaveTextContent('Option Two'); // score 2
618+
expect(options[2]).toHaveTextContent('Option One'); // score 1
619+
});
620+
621+
it('options with equal scores maintain their original relative order', async () => {
622+
// opt_two gets a higher score; opt_one and opt_three share the same low score
623+
// and should keep their original relative order (One before Three)
624+
render(
625+
<CompactSelect
626+
searchable
627+
searchPlaceholder="Search here…"
628+
searchMatcher={option => ({score: option.value === 'opt_two' ? 10 : 1})}
629+
options={[
630+
{value: 'opt_one', label: 'Option One'},
631+
{value: 'opt_two', label: 'Option Two'},
632+
{value: 'opt_three', label: 'Option Three'},
633+
]}
634+
value={undefined}
635+
onChange={jest.fn()}
636+
/>
637+
);
638+
639+
await userEvent.click(screen.getByRole('button'));
640+
await userEvent.click(screen.getByPlaceholderText('Search here…'));
641+
await userEvent.keyboard('opt');
642+
643+
const options = screen.getAllByRole('option');
644+
expect(options[0]).toHaveTextContent('Option Two'); // score 10
645+
expect(options[1]).toHaveTextContent('Option One'); // no score, original order
646+
expect(options[2]).toHaveTextContent('Option Three'); // no score, original order
647+
});
648+
649+
it('sizeLimit keeps highest-scored options visible, not first-in-order options', async () => {
650+
// Natural order: One (score 1), Two (score 3), Three (score 2).
651+
// sizeLimit=2 should keep the two highest-scored items: Two (3) and Three (2),
652+
// not the first two in original order: One (1) and Two (3).
653+
const scores: Record<string, number> = {opt_one: 1, opt_two: 3, opt_three: 2};
654+
655+
render(
656+
<CompactSelect
657+
searchable
658+
searchPlaceholder="Search here…"
659+
sizeLimit={2}
660+
searchMatcher={option => ({score: scores[String(option.value)] ?? 0})}
661+
options={[
662+
{value: 'opt_one', label: 'Option One'},
663+
{value: 'opt_two', label: 'Option Two'},
664+
{value: 'opt_three', label: 'Option Three'},
665+
]}
666+
value={undefined}
667+
onChange={jest.fn()}
668+
/>
669+
);
670+
671+
await userEvent.click(screen.getByRole('button'));
672+
await userEvent.click(screen.getByPlaceholderText('Search here…'));
673+
await userEvent.keyboard('opt');
674+
675+
const options = screen.getAllByRole('option');
676+
expect(options).toHaveLength(2);
677+
expect(options[0]).toHaveTextContent('Option Two'); // score 3, visible
678+
expect(options[1]).toHaveTextContent('Option Three'); // score 2, visible
679+
expect(screen.queryByRole('option', {name: 'Option One'})).not.toBeInTheDocument(); // score 1, hidden
680+
});
681+
682+
it('passes option and search string to searchMatcher', async () => {
683+
const searchMatcher = jest.fn().mockReturnValue({score: 1});
684+
685+
render(
686+
<CompactSelect
687+
searchable
688+
searchPlaceholder="Search here…"
689+
searchMatcher={searchMatcher}
690+
options={[
691+
{value: 'opt_one', label: 'Option One'},
692+
{value: 'opt_two', label: 'Option Two'},
693+
]}
694+
value={undefined}
695+
onChange={jest.fn()}
696+
/>
697+
);
698+
699+
await userEvent.click(screen.getByRole('button'));
700+
await userEvent.click(screen.getByPlaceholderText('Search here…'));
701+
await userEvent.keyboard('test');
702+
703+
expect(searchMatcher).toHaveBeenCalledWith(
704+
expect.objectContaining({value: 'opt_one', label: 'Option One'}),
705+
'test'
706+
);
707+
});
708+
563709
it('can search with sections', async () => {
564710
render(
565711
<CompactSelect
@@ -1022,7 +1168,9 @@ describe('CompactSelect', () => {
10221168
grid
10231169
searchable
10241170
searchPlaceholder="Search here…"
1025-
searchMatcher={(option, search) => String(option.value).endsWith(search)}
1171+
searchMatcher={(option, search) => ({
1172+
score: String(option.value).endsWith(search) ? 1 : 0,
1173+
})}
10261174
options={[
10271175
{value: 'opt_one', label: 'Option One'},
10281176
{value: 'opt_two', label: 'Option Two'},

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ import useOverlay from 'sentry/utils/useOverlay';
3333
import usePrevious from 'sentry/utils/usePrevious';
3434

3535
import type {SingleListProps} from './list';
36-
import type {SelectKey, SelectOptionOrSection, SelectOptionWithKey} from './types';
36+
import type {
37+
SearchMatchResult,
38+
SelectKey,
39+
SelectOptionOrSection,
40+
SelectOptionWithKey,
41+
} from './types';
3742

3843
// autoFocus react attribute is sync called on render, this causes
3944
// layout thrashing and is bad for performance. This thin wrapper function
@@ -68,7 +73,10 @@ interface ControlContextValue {
6873
/**
6974
* Custom function to determine whether an option matches the search query.
7075
*/
71-
searchMatcher?: (option: SelectOptionWithKey<SelectKey>, search: string) => boolean;
76+
searchMatcher?: (
77+
option: SelectOptionWithKey<SelectKey>,
78+
search: string
79+
) => SearchMatchResult;
7280
size?: FormSize;
7381
}
7482

@@ -180,10 +188,13 @@ export interface ControlProps
180188
/**
181189
* Custom function to determine whether an option matches the search query (applicable
182190
* only when `searchable` is true). Receives the option and the current search string,
183-
* and should return true if the option matches. If not provided, defaults to
184-
* case-insensitive substring matching on `textValue` or `label`.
191+
* and must return a `SearchMatchResult`. A score greater than 0 means the option
192+
* matches; options with higher scores are sorted first.
185193
*/
186-
searchMatcher?: (option: SelectOptionWithKey<SelectKey>, search: string) => boolean;
194+
searchMatcher?: (
195+
option: SelectOptionWithKey<SelectKey>,
196+
search: string
197+
) => SearchMatchResult;
187198
/**
188199
* The search input's placeholder text (applicable only when `searchable` is true).
189200
*/

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
getEscapedKey,
2424
getHiddenOptions,
2525
getSelectedOptions,
26+
getSortedItems,
2627
HiddenSectionToggle,
2728
shouldCloseOnSelect,
2829
} from './utils';
@@ -177,11 +178,16 @@ export function List<Value extends SelectKey>({
177178
const {overlayState, search, searchable, overlayIsOpen, searchMatcher} =
178179
useContext(ControlContext);
179180

180-
const hiddenOptions = useMemo(
181+
const {hidden: hiddenOptions, scores} = useMemo(
181182
() => getHiddenOptions(items, search, sizeLimit, searchMatcher),
182183
[items, search, sizeLimit, searchMatcher]
183184
);
184185

186+
const sortedItems = useMemo(
187+
() => (searchMatcher && scores.size > 0 ? getSortedItems(items, scores) : items),
188+
[items, scores, searchMatcher]
189+
);
190+
185191
/**
186192
* Props to be passed into useListState()
187193
*/
@@ -250,7 +256,7 @@ export function List<Value extends SelectKey>({
250256
const listState = useListState({
251257
...props,
252258
...listStateProps,
253-
items,
259+
items: sortedItems,
254260
});
255261

256262
// In composite selects, focus should seamlessly move from one region (list) to

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ export type SelectOptionOrSection<Value extends SelectKey> =
3838
| SelectOption<Value>
3939
| SelectSection<Value>;
4040

41+
/**
42+
* The result of a custom `searchMatcher` function. Returning this (instead of a plain
43+
* boolean) allows callers to influence how matching options are sorted: options with a
44+
* higher `score` are shown first. To hide an option, return `{score: 0}.
45+
*/
46+
export interface SearchMatchResult {
47+
/**
48+
* Match quality score. Higher values cause the option to appear earlier in the list.
49+
* Options that match but return no score maintain their original order relative to
50+
* each other.
51+
*/
52+
score: number;
53+
}
54+
4155
export interface SelectOptionWithKey<
4256
Value extends SelectKey,
4357
> extends SelectOption<Value> {

0 commit comments

Comments
 (0)