searchBar.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import {useCallback} from 'react';
  2. import styled from '@emotion/styled';
  3. import orderBy from 'lodash/orderBy';
  4. // eslint-disable-next-line no-restricted-imports
  5. import {fetchTagValues} from 'sentry/actionCreators/tags';
  6. import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder';
  7. import type {FilterKeySection} from 'sentry/components/searchQueryBuilder/types';
  8. import SmartSearchBar from 'sentry/components/smartSearchBar';
  9. import type {SearchGroup} from 'sentry/components/smartSearchBar/types';
  10. import {ItemType} from 'sentry/components/smartSearchBar/types';
  11. import {IconStar} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import type {Organization, Tag, TagCollection} from 'sentry/types';
  15. import {SavedSearchType} from 'sentry/types';
  16. import {getUtcDateString} from 'sentry/utils/dates';
  17. import {
  18. DEVICE_CLASS_TAG_VALUES,
  19. FieldKey,
  20. FieldKind,
  21. getFieldDefinition,
  22. isDeviceClass,
  23. } from 'sentry/utils/fields';
  24. import useApi from 'sentry/utils/useApi';
  25. import usePageFilters from 'sentry/utils/usePageFilters';
  26. import type {WithIssueTagsProps} from 'sentry/utils/withIssueTags';
  27. import withIssueTags from 'sentry/utils/withIssueTags';
  28. const getSupportedTags = (supportedTags: TagCollection, org: Organization) => {
  29. const include_priority = org.features.includes('issue-priority-ui');
  30. return Object.fromEntries(
  31. Object.keys(supportedTags)
  32. .map(key => [
  33. key,
  34. {
  35. ...supportedTags[key],
  36. kind:
  37. getFieldDefinition(key)?.kind ??
  38. (supportedTags[key].predefined ? FieldKind.FIELD : FieldKind.TAG),
  39. },
  40. ])
  41. .filter(([key, _]) => (key === FieldKey.ISSUE_PRIORITY ? include_priority : true))
  42. );
  43. };
  44. const getFilterKeySections = (
  45. tags: TagCollection,
  46. org: Organization
  47. ): FilterKeySection[] => {
  48. const allTags: Tag[] = orderBy(Object.values(getSupportedTags(tags, org)), 'key');
  49. const eventTags = allTags.filter(tag => tag.kind === FieldKind.TAG);
  50. const issueFields = allTags.filter(tag => tag.kind === FieldKind.FIELD);
  51. return [
  52. {
  53. value: FieldKind.FIELD,
  54. label: t('Issue Fields'),
  55. children: issueFields,
  56. },
  57. {
  58. value: FieldKind.TAG,
  59. label: t('Event Tags'),
  60. children: eventTags,
  61. },
  62. ];
  63. };
  64. interface Props extends React.ComponentProps<typeof SmartSearchBar>, WithIssueTagsProps {
  65. organization: Organization;
  66. }
  67. const EXCLUDED_TAGS = ['environment'];
  68. function IssueListSearchBar({organization, tags, ...props}: Props) {
  69. const api = useApi();
  70. const {selection: pageFilters} = usePageFilters();
  71. const tagValueLoader = useCallback(
  72. (key: string, search: string) => {
  73. const orgSlug = organization.slug;
  74. const projectIds = pageFilters.projects.map(id => id.toString());
  75. const endpointParams = {
  76. start: getUtcDateString(pageFilters.datetime.start),
  77. end: getUtcDateString(pageFilters.datetime.end),
  78. statsPeriod: pageFilters.datetime.period,
  79. };
  80. return fetchTagValues({
  81. api,
  82. orgSlug,
  83. tagKey: key,
  84. search,
  85. projectIds,
  86. endpointParams,
  87. });
  88. },
  89. [
  90. api,
  91. organization.slug,
  92. pageFilters.datetime.end,
  93. pageFilters.datetime.period,
  94. pageFilters.datetime.start,
  95. pageFilters.projects,
  96. ]
  97. );
  98. const getTagValues = useCallback(
  99. async (tag: Tag, query: string): Promise<string[]> => {
  100. // device.class is stored as "numbers" in snuba, but we want to suggest high, medium,
  101. // and low search filter values because discover maps device.class to these values.
  102. if (isDeviceClass(tag.key)) {
  103. return DEVICE_CLASS_TAG_VALUES;
  104. }
  105. const values = await tagValueLoader(tag.key, query);
  106. return values.map(({value}) => {
  107. // Truncate results to 5000 characters to avoid exceeding the max url query length
  108. // The message attribute for example can be 8192 characters.
  109. if (typeof value === 'string' && value.length > 5000) {
  110. return value.substring(0, 5000);
  111. }
  112. return value;
  113. });
  114. },
  115. [tagValueLoader]
  116. );
  117. const recommendedGroup: SearchGroup = {
  118. title: t('Popular Filters'),
  119. type: 'header',
  120. icon: <IconStar size="xs" />,
  121. childrenWrapper: RecommendedWrapper,
  122. children: [
  123. {
  124. type: ItemType.RECOMMENDED,
  125. kind: FieldKind.FIELD,
  126. title: t('Issue Category'),
  127. value: 'issue.category:',
  128. },
  129. {
  130. type: ItemType.RECOMMENDED,
  131. kind: FieldKind.FIELD,
  132. title: t('Error Level'),
  133. value: 'level:',
  134. },
  135. {
  136. type: ItemType.RECOMMENDED,
  137. kind: FieldKind.FIELD,
  138. title: t('Assignee'),
  139. value: 'assigned_or_suggested:',
  140. },
  141. {
  142. type: ItemType.RECOMMENDED,
  143. kind: FieldKind.FIELD,
  144. title: t('Unhandled Events'),
  145. value: 'error.unhandled:true ',
  146. },
  147. {
  148. type: ItemType.RECOMMENDED,
  149. kind: FieldKind.FIELD,
  150. title: t('Latest Release'),
  151. value: 'release:latest ',
  152. },
  153. {
  154. type: ItemType.RECOMMENDED,
  155. kind: FieldKind.TAG,
  156. title: t('Custom Tags'),
  157. // Shows only tags when clicked
  158. applyFilter: item => item.kind === FieldKind.TAG,
  159. },
  160. ],
  161. };
  162. if (organization.features.includes('issue-stream-search-query-builder')) {
  163. return (
  164. <SearchQueryBuilder
  165. className={props.className}
  166. initialQuery={props.query ?? ''}
  167. getTagValues={getTagValues}
  168. filterKeySections={getFilterKeySections(tags, organization)}
  169. onSearch={props.onSearch}
  170. onBlur={props.onBlur}
  171. onChange={value => {
  172. props.onClose?.(value, {validSearch: true});
  173. }}
  174. />
  175. );
  176. }
  177. return (
  178. <SmartSearchBar
  179. hasRecentSearches
  180. projectIds={pageFilters.projects}
  181. savedSearchType={SavedSearchType.ISSUE}
  182. onGetTagValues={getTagValues}
  183. excludedTags={EXCLUDED_TAGS}
  184. maxMenuHeight={500}
  185. supportedTags={getSupportedTags(tags, organization)}
  186. defaultSearchGroup={recommendedGroup}
  187. organization={organization}
  188. {...props}
  189. />
  190. );
  191. }
  192. export default withIssueTags(IssueListSearchBar);
  193. // Using grid-template-rows to order the items top to bottom, then left to right
  194. const RecommendedWrapper = styled('div')`
  195. display: grid;
  196. grid-template-rows: 1fr 1fr 1fr;
  197. grid-auto-flow: column;
  198. gap: ${space(1)};
  199. padding: ${space(1)};
  200. text-align: left;
  201. line-height: 1.2;
  202. & > li {
  203. ${p => p.theme.overflowEllipsis}
  204. border-radius: ${p => p.theme.borderRadius};
  205. border: 1px solid ${p => p.theme.border};
  206. padding: ${space(1)} ${space(1.5)};
  207. margin: 0;
  208. }
  209. @media (min-width: ${p => p.theme.breakpoints.small}) {
  210. grid-template-rows: 1fr 1fr;
  211. gap: ${space(1.5)};
  212. padding: ${space(1.5)};
  213. text-align: center;
  214. & > li {
  215. padding: ${space(1.5)} ${space(2)};
  216. }
  217. }
  218. `;