Skip to content

Commit 22cb937

Browse files
authored
feat(search-shortcuts): Group assignee search suggestions (#52764)
1 parent 9aab957 commit 22cb937

File tree

7 files changed

+290
-105
lines changed

7 files changed

+290
-105
lines changed

static/app/components/smartSearchBar/index.spec.tsx

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import {Fragment} from 'react';
2+
13
import {
24
act,
35
fireEvent,
@@ -38,7 +40,6 @@ describe('SmartSearchBar', function () {
3840
},
3941
};
4042

41-
MockApiClient.clearMockResponses();
4243
MockApiClient.addMockResponse({
4344
url: '/organizations/org-slug/recent-searches/',
4445
body: [],
@@ -609,6 +610,105 @@ describe('SmartSearchBar', function () {
609610
jest.useRealTimers();
610611
});
611612

613+
it('autocompletes assigned from string values', async function () {
614+
const mockOnChange = jest.fn();
615+
616+
render(
617+
<SmartSearchBar
618+
{...defaultProps}
619+
query=""
620+
onChange={mockOnChange}
621+
supportedTags={{
622+
assigned: {
623+
key: 'assigned',
624+
name: 'assigned',
625+
predefined: true,
626+
values: ['me', '[me, none]', '#team-a'],
627+
},
628+
}}
629+
/>
630+
);
631+
632+
const textbox = screen.getByRole('textbox');
633+
await userEvent.type(textbox, 'assigned:', {delay: null});
634+
635+
await userEvent.click(await screen.findByRole('option', {name: /#team-a/}), {
636+
delay: null,
637+
});
638+
639+
await waitFor(() => {
640+
expect(mockOnChange).toHaveBeenLastCalledWith(
641+
'assigned:#team-a ',
642+
expect.anything()
643+
);
644+
});
645+
});
646+
647+
it('autocompletes assigned from SearchGroup objects', async function () {
648+
const mockOnChange = jest.fn();
649+
650+
render(
651+
<SmartSearchBar
652+
{...defaultProps}
653+
query=""
654+
onChange={mockOnChange}
655+
supportedTags={{
656+
assigned: {
657+
key: 'assigned',
658+
name: 'assigned',
659+
predefined: true,
660+
values: [
661+
{
662+
title: 'Suggested Values',
663+
type: 'header',
664+
icon: <Fragment />,
665+
children: [
666+
{
667+
value: 'me',
668+
desc: 'me',
669+
type: ItemType.TAG_VALUE,
670+
},
671+
],
672+
},
673+
{
674+
title: 'All Values',
675+
type: 'header',
676+
icon: <Fragment />,
677+
children: [
678+
{
679+
value: '#team-a',
680+
desc: '#team-a',
681+
type: ItemType.TAG_VALUE,
682+
},
683+
],
684+
},
685+
],
686+
},
687+
}}
688+
/>
689+
);
690+
691+
const textbox = screen.getByRole('textbox');
692+
await userEvent.type(textbox, 'assigned:', {delay: null});
693+
694+
expect(await screen.findByText('Suggested Values')).toBeInTheDocument();
695+
expect(screen.getByText('All Values')).toBeInTheDocument();
696+
697+
// Filter down to "team"
698+
await userEvent.type(textbox, 'team', {delay: null});
699+
700+
expect(screen.queryByText('Suggested Values')).not.toBeInTheDocument();
701+
702+
await userEvent.click(screen.getByRole('option', {name: /#team-a/}), {delay: null});
703+
704+
await waitFor(() => {
705+
expect(mockOnChange).toHaveBeenLastCalledWith(
706+
'assigned:#team-a ',
707+
expect.anything()
708+
);
709+
});
710+
});
711+
612712
it('autocompletes tag values (predefined values with spaces)', async function () {
613713
jest.useFakeTimers();
614714
const mockOnChange = jest.fn();

static/app/components/smartSearchBar/index.tsx

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
import {
7272
addSpace,
7373
createSearchGroups,
74+
escapeTagValue,
7475
filterKeysFromQuery,
7576
generateOperatorEntryMap,
7677
getAutoCompleteGroupForInvalidWildcard,
@@ -108,13 +109,6 @@ const generateOpAutocompleteGroup = (
108109
};
109110
};
110111

111-
const escapeValue = (value: string): string => {
112-
// Wrap in quotes if there is a space
113-
return value.includes(' ') || value.includes('"')
114-
? `"${value.replace(/"/g, '\\"')}"`
115-
: value;
116-
};
117-
118112
export type ActionProps = {
119113
api: Client;
120114
/**
@@ -1199,7 +1193,7 @@ class SmartSearchBar extends Component<DefaultProps & Props, State> {
11991193
this.setState({noValueQuery});
12001194

12011195
return values.map(value => {
1202-
const escapedValue = escapeValue(value);
1196+
const escapedValue = escapeTagValue(value);
12031197
return {
12041198
value: escapedValue,
12051199
desc: escapedValue,
@@ -1215,11 +1209,27 @@ class SmartSearchBar extends Component<DefaultProps & Props, State> {
12151209
* Returns array of tag values that substring match `query`; invokes `callback`
12161210
* with results
12171211
*/
1218-
getPredefinedTagValues = (tag: Tag, query: string): SearchItem[] =>
1219-
(tag.values ?? [])
1212+
getPredefinedTagValues = (
1213+
tag: Tag,
1214+
query: string
1215+
): AutocompleteGroup['searchItems'] => {
1216+
const groupOrValue = tag.values ?? [];
1217+
1218+
// Is an array of SearchGroup
1219+
if (groupOrValue.some(item => typeof item === 'object')) {
1220+
return (groupOrValue as SearchGroup[]).map(group => {
1221+
return {
1222+
...group,
1223+
children: group.children?.filter(child => child.value?.includes(query)),
1224+
};
1225+
});
1226+
}
1227+
1228+
// Is an array of strings
1229+
return (groupOrValue as string[])
12201230
.filter(value => value.includes(query))
12211231
.map((value, i) => {
1222-
const escapedValue = escapeValue(value);
1232+
const escapedValue = escapeTagValue(value);
12231233
return {
12241234
value: escapedValue,
12251235
desc: escapedValue,
@@ -1229,6 +1239,7 @@ class SmartSearchBar extends Component<DefaultProps & Props, State> {
12291239
: false,
12301240
};
12311241
});
1242+
};
12321243

12331244
/**
12341245
* Get recent searches

static/app/components/smartSearchBar/types.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export type Shortcut = {
9494

9595
export type AutocompleteGroup = {
9696
recentSearchItems: SearchItem[] | undefined;
97-
searchItems: SearchItem[];
97+
searchItems: SearchItem[] | SearchGroup[];
9898
tagName: string;
9999
type: ItemType;
100100
};

static/app/components/smartSearchBar/utils.tsx

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ function getIconForTypeAndTag(type: ItemType, tagName: string) {
8686
case 'is':
8787
return <IconToggle size="xs" />;
8888
case 'assigned':
89+
case 'assigned_or_suggested':
8990
case 'bookmarks':
9091
return <IconUser size="xs" />;
9192
case 'firstSeen':
@@ -152,8 +153,19 @@ interface SearchGroups {
152153
searchGroups: SearchGroup[];
153154
}
154155

156+
function isSearchGroup(searchItem: SearchItem | SearchGroup): searchItem is SearchGroup {
157+
return (
158+
(searchItem as SearchGroup).children !== undefined && searchItem.type === 'header'
159+
);
160+
}
161+
162+
function isSearchGroupArray(items: SearchItem[] | SearchGroup[]): items is SearchGroup[] {
163+
// Typescript doesn't like that there's no shared properties between SearchItem and SearchGroup
164+
return (items as any[]).every(isSearchGroup);
165+
}
166+
155167
export function createSearchGroups(
156-
searchItems: SearchItem[],
168+
searchGroupItems: SearchItem[] | SearchGroup[],
157169
recentSearchItems: SearchItem[] | undefined,
158170
tagName: string,
159171
type: ItemType,
@@ -163,19 +175,45 @@ export function createSearchGroups(
163175
defaultSearchGroup?: SearchGroup,
164176
fieldDefinitionGetter: typeof getFieldDefinition = getFieldDefinition
165177
): SearchGroups {
166-
const fieldDefinition = fieldDefinitionGetter(tagName);
167-
168-
const activeSearchItem = 0;
169-
const {searchItems: filteredSearchItems, recentSearchItems: filteredRecentSearchItems} =
170-
filterSearchItems(searchItems, recentSearchItems, maxSearchItems, queryCharsLeft);
171-
172178
const searchGroup: SearchGroup = {
173179
title: getTitleForType(type),
174180
type: invalidTypes.includes(type) ? type : 'header',
175181
icon: getIconForTypeAndTag(type, tagName),
176-
children: filteredSearchItems,
182+
children: [],
177183
};
178184

185+
if (isSearchGroupArray(searchGroupItems)) {
186+
// Autocomplete item has provided its own search groups
187+
const searchGroups = searchGroupItems
188+
.map(group => {
189+
const {searchItems: filteredSearchItems} = filterSearchItems(
190+
group.children,
191+
recentSearchItems,
192+
maxSearchItems,
193+
queryCharsLeft
194+
);
195+
return {...group, children: filteredSearchItems};
196+
})
197+
.filter(group => group.children.length > 0);
198+
return {
199+
// Fallback to the blank search group when "no items found"
200+
searchGroups: searchGroups.length ? searchGroups : [searchGroup],
201+
flatSearchItems: searchGroups.flatMap(item => item.children ?? []),
202+
activeSearchItem: -1,
203+
};
204+
}
205+
206+
const fieldDefinition = fieldDefinitionGetter(tagName);
207+
208+
const activeSearchItem = 0;
209+
const {searchItems: filteredSearchItems, recentSearchItems: filteredRecentSearchItems} =
210+
filterSearchItems(
211+
searchGroupItems,
212+
recentSearchItems,
213+
maxSearchItems,
214+
queryCharsLeft
215+
);
216+
179217
const recentSearchGroup: SearchGroup | undefined =
180218
filteredRecentSearchItems && filteredRecentSearchItems.length > 0
181219
? {
@@ -186,6 +224,8 @@ export function createSearchGroups(
186224
}
187225
: undefined;
188226

227+
searchGroup.children = filteredSearchItems;
228+
189229
if (searchGroup.children && !!searchGroup.children.length) {
190230
searchGroup.children[activeSearchItem] = {
191231
...searchGroup.children[activeSearchItem],
@@ -652,3 +692,10 @@ export function getAutoCompleteGroupForInvalidWildcard(searchText: string) {
652692
},
653693
];
654694
}
695+
696+
export function escapeTagValue(value: string): string {
697+
// Wrap in quotes if there is a space
698+
return value.includes(' ') || value.includes('"')
699+
? `"${value.replace(/"/g, '\\"')}"`
700+
: value;
701+
}

static/app/types/group.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import type {SearchGroup} from 'sentry/components/smartSearchBar/types';
12
import type {PlatformKey} from 'sentry/data/platformCategories';
2-
import {FieldKind} from 'sentry/utils/fields';
3+
import type {FieldKind} from 'sentry/utils/fields';
34

45
import type {Actor, TimeseriesValue} from './core';
56
import type {Event, EventMetadata, EventOrGroupType, Level} from './event';
@@ -132,7 +133,10 @@ export type Tag = {
132133
maxSuggestedValues?: number;
133134
predefined?: boolean;
134135
totalValues?: number;
135-
values?: string[];
136+
/**
137+
* Usually values are strings, but a predefined tag can define its SearchGroups
138+
*/
139+
values?: string[] | SearchGroup[];
136140
};
137141

138142
export type TagCollection = Record<string, Tag>;

static/app/utils/withIssueTags.spec.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {act, render, screen, waitFor} from 'sentry-test/reactTestingLibrary';
22

3+
import {SearchGroup} from 'sentry/components/smartSearchBar/types';
34
import MemberListStore from 'sentry/stores/memberListStore';
45
import TagStore from 'sentry/stores/tagStore';
56
import TeamStore from 'sentry/stores/teamStore';
@@ -23,6 +24,7 @@ function MyComponent(props: MyComponentProps) {
2324

2425
describe('withIssueTags HoC', function () {
2526
beforeEach(() => {
27+
TeamStore.reset();
2628
TagStore.reset();
2729
MemberListStore.loadInitialData([]);
2830
});
@@ -76,12 +78,44 @@ describe('withIssueTags HoC', function () {
7678

7779
expect(
7880
screen.getByText(
79-
/assigned: me, \[me, none\], foo@example.com, joe@example.com, #best-team-na/
81+
/assigned: me, \[me, none\], #best-team-na, foo@example.com, joe@example.com/
8082
)
8183
).toBeInTheDocument();
8284

8385
expect(
8486
screen.getByText(/bookmarks: me, foo@example.com, joe@example.com/)
8587
).toBeInTheDocument();
8688
});
89+
90+
it('groups assignees and puts suggestions first', function () {
91+
const Container = withIssueTags(({tags}: MyComponentProps) => (
92+
<div>
93+
{(tags?.assigned?.values as SearchGroup[])?.map(searchGroup => (
94+
<div data-test-id={searchGroup.title} key={searchGroup.title}>
95+
{searchGroup.children?.map(item => item.desc).join(', ')}
96+
</div>
97+
))}
98+
</div>
99+
));
100+
const organization = TestStubs.Organization({features: ['issue-search-shortcuts']});
101+
TeamStore.loadInitialData([
102+
TestStubs.Team({id: 1, slug: 'best-team', name: 'Best Team', isMember: true}),
103+
TestStubs.Team({id: 2, slug: 'worst-team', name: 'Worst Team', isMember: false}),
104+
]);
105+
MemberListStore.loadInitialData([
106+
TestStubs.User(),
107+
TestStubs.User({username: '[email protected]'}),
108+
]);
109+
render(<Container organization={organization} forwardedValue="value" />, {
110+
organization,
111+
});
112+
113+
expect(screen.getByTestId('Suggested Values')).toHaveTextContent(
114+
'me, [me, none], #best-team'
115+
);
116+
117+
expect(screen.getByTestId('All Values')).toHaveTextContent(
118+
119+
);
120+
});
87121
});

0 commit comments

Comments
 (0)