searchBar.tsx 6.6 KB

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