metricSearchBar.tsx 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import {useCallback, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import {useId} from '@react-aria/utils';
  4. import memoize from 'lodash/memoize';
  5. import type {SearchConfig} from 'sentry/components/searchSyntax/parser';
  6. import {
  7. FilterType,
  8. joinQuery,
  9. parseSearch,
  10. Token,
  11. } from 'sentry/components/searchSyntax/parser';
  12. import {treeTransformer} from 'sentry/components/searchSyntax/utils';
  13. import type {SmartSearchBarProps} from 'sentry/components/smartSearchBar';
  14. import SmartSearchBar from 'sentry/components/smartSearchBar';
  15. import {t} from 'sentry/locale';
  16. import type {MRI, TagCollection} from 'sentry/types';
  17. import {SavedSearchType} from 'sentry/types';
  18. import {getUseCaseFromMRI} from 'sentry/utils/metrics/mri';
  19. import {useMetricsTags} from 'sentry/utils/metrics/useMetricsTags';
  20. import useApi from 'sentry/utils/useApi';
  21. import useOrganization from 'sentry/utils/useOrganization';
  22. import usePageFilters from 'sentry/utils/usePageFilters';
  23. interface MetricSearchBarProps extends Partial<SmartSearchBarProps> {
  24. onChange: (value: string) => void;
  25. blockedTags?: string[];
  26. disabled?: boolean;
  27. mri?: MRI;
  28. projectIds?: string[];
  29. query?: string;
  30. }
  31. const EMPTY_ARRAY = [];
  32. const EMPTY_SET = new Set<never>();
  33. export function ensureQuotedTextFilters(
  34. query: string,
  35. configOverrides?: Partial<SearchConfig>
  36. ) {
  37. const parsedSearch = parseSearch(query, configOverrides);
  38. if (!parsedSearch) {
  39. return query;
  40. }
  41. const newTree = treeTransformer({
  42. tree: parsedSearch,
  43. transform: token => {
  44. if (token.type === Token.FILTER && token.filter === FilterType.TEXT) {
  45. if (!token.value.quoted) {
  46. return {
  47. ...token,
  48. // joinQuery() does not access nested tokens, so we need to manipulate the text of the filter instead of it's value
  49. text: `${token.key.text}:"${token.value.text}"`,
  50. };
  51. }
  52. }
  53. return token;
  54. },
  55. });
  56. return joinQuery(newTree);
  57. }
  58. export function MetricSearchBar({
  59. mri,
  60. blockedTags,
  61. disabled,
  62. onChange,
  63. query,
  64. projectIds,
  65. id: idProp,
  66. ...props
  67. }: MetricSearchBarProps) {
  68. const org = useOrganization();
  69. const api = useApi();
  70. const {selection} = usePageFilters();
  71. const id = useId(idProp);
  72. const projectIdNumbers = useMemo(
  73. () => projectIds?.map(projectId => parseInt(projectId, 10)),
  74. [projectIds]
  75. );
  76. const {data: tags = EMPTY_ARRAY, isLoading} = useMetricsTags(
  77. mri,
  78. {
  79. ...selection,
  80. projects: projectIdNumbers,
  81. },
  82. true,
  83. blockedTags
  84. );
  85. const supportedTags: TagCollection = useMemo(
  86. () => tags.reduce((acc, tag) => ({...acc, [tag.key]: tag}), {}),
  87. [tags]
  88. );
  89. const searchConfig = useMemo(
  90. () => ({
  91. booleanKeys: EMPTY_SET,
  92. dateKeys: EMPTY_SET,
  93. durationKeys: EMPTY_SET,
  94. numericKeys: EMPTY_SET,
  95. percentageKeys: EMPTY_SET,
  96. sizeKeys: EMPTY_SET,
  97. textOperatorKeys: EMPTY_SET,
  98. supportedTags,
  99. disallowFreeText: true,
  100. }),
  101. [supportedTags]
  102. );
  103. const fetchTagValues = useMemo(() => {
  104. const fn = memoize((tagKey: string) => {
  105. // clear response from cache after 10 seconds
  106. setTimeout(() => {
  107. fn.cache.delete(tagKey);
  108. }, 10000);
  109. return api.requestPromise(`/organizations/${org.slug}/metrics/tags/${tagKey}/`, {
  110. query: {
  111. metric: mri,
  112. useCase: getUseCaseFromMRI(mri),
  113. project: selection.projects,
  114. },
  115. });
  116. });
  117. return fn;
  118. }, [api, mri, org.slug, selection.projects]);
  119. const getTagValues = useCallback(
  120. async (tag: any, search: string) => {
  121. const tagsValues = await fetchTagValues(tag.key);
  122. return tagsValues
  123. .filter(
  124. tv =>
  125. tv.value !== '' &&
  126. tv.value.toLocaleLowerCase().includes(search.toLocaleLowerCase())
  127. )
  128. .map(tv => tv.value);
  129. },
  130. [fetchTagValues]
  131. );
  132. const handleChange = useCallback(
  133. (value: string, {validSearch} = {validSearch: true}) => {
  134. if (!validSearch) {
  135. return;
  136. }
  137. onChange(ensureQuotedTextFilters(value, searchConfig));
  138. },
  139. [onChange, searchConfig]
  140. );
  141. return (
  142. <WideSearchBar
  143. id={id}
  144. disabled={disabled}
  145. maxMenuHeight={220}
  146. organization={org}
  147. onGetTagValues={getTagValues}
  148. // don't highlight tags while loading as we don't know yet if they are supported
  149. highlightUnsupportedTags={!isLoading}
  150. onClose={handleChange}
  151. onSearch={handleChange}
  152. placeholder={t('Filter by tags')}
  153. query={query}
  154. savedSearchType={SavedSearchType.METRIC}
  155. {...searchConfig}
  156. {...props}
  157. />
  158. );
  159. }
  160. const WideSearchBar = styled(SmartSearchBar)`
  161. width: 100%;
  162. opacity: ${p => (p.disabled ? '0.6' : '1')};
  163. `;