queryBuilder.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {CompactSelect} from 'sentry/components/compactSelect';
  4. import SearchBar, {SearchBarProps} from 'sentry/components/events/searchBar';
  5. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  6. import Tag from 'sentry/components/tag';
  7. import {IconLightning, IconReleases} from 'sentry/icons';
  8. import {t} from 'sentry/locale';
  9. import {space} from 'sentry/styles/space';
  10. import {MRI, SavedSearchType, TagCollection} from 'sentry/types';
  11. import {
  12. defaultMetricDisplayType,
  13. getReadableMetricType,
  14. isAllowedOp,
  15. MetricDisplayType,
  16. MetricsQuery,
  17. MetricWidgetQueryParams,
  18. } from 'sentry/utils/metrics';
  19. import {formatMRI, getUseCaseFromMRI} from 'sentry/utils/metrics/mri';
  20. import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
  21. import {useMetricsTags} from 'sentry/utils/metrics/useMetricsTags';
  22. import useApi from 'sentry/utils/useApi';
  23. import useKeyPress from 'sentry/utils/useKeyPress';
  24. import useOrganization from 'sentry/utils/useOrganization';
  25. import usePageFilters from 'sentry/utils/usePageFilters';
  26. type QueryBuilderProps = {
  27. displayType: MetricDisplayType; // TODO(ddm): move display type out of the query builder
  28. metricsQuery: Pick<MetricsQuery, 'mri' | 'op' | 'query' | 'groupBy'>;
  29. onChange: (data: Partial<MetricWidgetQueryParams>) => void;
  30. projects: number[];
  31. powerUserMode?: boolean;
  32. };
  33. export function QueryBuilder({
  34. metricsQuery,
  35. projects,
  36. displayType,
  37. powerUserMode,
  38. onChange,
  39. }: QueryBuilderProps) {
  40. const {data: meta, isLoading: isMetaLoading} = useMetricsMeta(projects);
  41. const mriModeKeyPressed = useKeyPress('`', undefined, true);
  42. const [mriMode, setMriMode] = useState(powerUserMode); // power user mode that shows raw MRI instead of metrics names
  43. useEffect(() => {
  44. if (mriModeKeyPressed && !powerUserMode) {
  45. setMriMode(!mriMode);
  46. }
  47. // eslint-disable-next-line react-hooks/exhaustive-deps
  48. }, [mriModeKeyPressed, powerUserMode]);
  49. const {data: tags = []} = useMetricsTags(metricsQuery.mri, projects);
  50. const metaArr = useMemo(() => {
  51. if (mriMode) {
  52. return Object.values(meta);
  53. }
  54. return Object.values(meta).filter(
  55. metric => metric.mri.includes(':custom/') || metric.mri === metricsQuery.mri
  56. );
  57. }, [meta, metricsQuery.mri, mriMode]);
  58. // Reset the query data if the selected metric is no longer available
  59. useEffect(() => {
  60. if (
  61. metricsQuery.mri &&
  62. !isMetaLoading &&
  63. !metaArr.find(metric => metric.mri === metricsQuery.mri)
  64. ) {
  65. onChange({mri: '' as MRI, op: '', groupBy: []});
  66. }
  67. }, [isMetaLoading, metaArr, metricsQuery.mri, onChange]);
  68. if (!meta) {
  69. return null;
  70. }
  71. return (
  72. <QueryBuilderWrapper>
  73. <QueryBuilderRow>
  74. <WrapPageFilterBar>
  75. <CompactSelect
  76. searchable
  77. sizeLimit={100}
  78. triggerProps={{prefix: t('Metric'), size: 'sm'}}
  79. options={metaArr.map(metric => ({
  80. label: mriMode ? metric.mri : formatMRI(metric.mri),
  81. value: metric.mri,
  82. trailingItems: mriMode ? undefined : (
  83. <Fragment>
  84. <Tag tooltipText={t('Type')}>{getReadableMetricType(metric.type)}</Tag>
  85. <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
  86. </Fragment>
  87. ),
  88. }))}
  89. value={metricsQuery.mri}
  90. onChange={option => {
  91. const availableOps = meta[option.value]?.operations.filter(isAllowedOp);
  92. // @ts-expect-error .op is an operation
  93. const selectedOp = availableOps.includes(metricsQuery.op ?? '')
  94. ? metricsQuery.op
  95. : availableOps[0];
  96. onChange({
  97. mri: option.value,
  98. op: selectedOp,
  99. groupBy: undefined,
  100. focusedSeries: undefined,
  101. displayType: getWidgetDisplayType(option.value, selectedOp),
  102. });
  103. }}
  104. />
  105. <CompactSelect
  106. triggerProps={{prefix: t('Op'), size: 'sm'}}
  107. options={
  108. meta[metricsQuery.mri]?.operations.filter(isAllowedOp).map(op => ({
  109. label: op,
  110. value: op,
  111. })) ?? []
  112. }
  113. disabled={!metricsQuery.mri}
  114. value={metricsQuery.op}
  115. onChange={option =>
  116. onChange({
  117. op: option.value,
  118. })
  119. }
  120. />
  121. <CompactSelect
  122. multiple
  123. triggerProps={{prefix: t('Group by'), size: 'sm'}}
  124. options={tags.map(tag => ({
  125. label: tag.key,
  126. value: tag.key,
  127. trailingItems: (
  128. <Fragment>
  129. {tag.key === 'release' && <IconReleases size="xs" />}
  130. {tag.key === 'transaction' && <IconLightning size="xs" />}
  131. </Fragment>
  132. ),
  133. }))}
  134. disabled={!metricsQuery.mri}
  135. value={metricsQuery.groupBy}
  136. onChange={options =>
  137. onChange({
  138. groupBy: options.map(o => o.value),
  139. focusedSeries: undefined,
  140. })
  141. }
  142. />
  143. <CompactSelect
  144. triggerProps={{prefix: t('Display'), size: 'sm'}}
  145. value={displayType ?? defaultMetricDisplayType}
  146. options={[
  147. {
  148. value: MetricDisplayType.LINE,
  149. label: t('Line'),
  150. },
  151. {
  152. value: MetricDisplayType.AREA,
  153. label: t('Area'),
  154. },
  155. {
  156. value: MetricDisplayType.BAR,
  157. label: t('Bar'),
  158. },
  159. ]}
  160. onChange={({value}) => {
  161. onChange({displayType: value});
  162. }}
  163. />
  164. </WrapPageFilterBar>
  165. </QueryBuilderRow>
  166. <QueryBuilderRow>
  167. <MetricSearchBar
  168. // TODO(aknaus): clean up projectId type in ddm
  169. projectIds={projects.map(id => id.toString())}
  170. mri={metricsQuery.mri}
  171. disabled={!metricsQuery.mri}
  172. onChange={query => onChange({query})}
  173. query={metricsQuery.query}
  174. />
  175. </QueryBuilderRow>
  176. </QueryBuilderWrapper>
  177. );
  178. }
  179. interface MetricSearchBarProps
  180. extends Omit<Partial<SearchBarProps>, 'tags' | 'projectIds'> {
  181. onChange: (value: string) => void;
  182. projectIds: string[];
  183. disabled?: boolean;
  184. mri?: MRI;
  185. query?: string;
  186. }
  187. const EMPTY_ARRAY = [];
  188. export function MetricSearchBar({
  189. mri,
  190. disabled,
  191. onChange,
  192. query,
  193. projectIds,
  194. ...props
  195. }: MetricSearchBarProps) {
  196. const org = useOrganization();
  197. const api = useApi();
  198. const {selection} = usePageFilters();
  199. const projectIdNumbers = useMemo(
  200. () => projectIds.map(id => parseInt(id, 10)),
  201. [projectIds]
  202. );
  203. const {data: tags = EMPTY_ARRAY} = useMetricsTags(mri, projectIdNumbers);
  204. const supportedTags: TagCollection = useMemo(
  205. () => tags.reduce((acc, tag) => ({...acc, [tag.key]: tag}), {}),
  206. [tags]
  207. );
  208. // TODO(ddm): try to use useApiQuery here
  209. const getTagValues = useCallback(
  210. async tag => {
  211. const useCase = getUseCaseFromMRI(mri);
  212. const tagsValues = await api.requestPromise(
  213. `/organizations/${org.slug}/metrics/tags/${tag.key}/`,
  214. {
  215. query: {
  216. metric: mri,
  217. useCase,
  218. project: selection.projects,
  219. },
  220. }
  221. );
  222. return tagsValues.filter(tv => tv.value !== '').map(tv => tv.value);
  223. },
  224. [api, mri, org.slug, selection.projects]
  225. );
  226. const handleChange = useCallback(
  227. (value: string, {validSearch} = {validSearch: true}) => {
  228. if (validSearch) {
  229. onChange(value);
  230. }
  231. },
  232. [onChange]
  233. );
  234. return (
  235. <WideSearchBar
  236. disabled={disabled}
  237. maxMenuHeight={220}
  238. organization={org}
  239. onGetTagValues={getTagValues}
  240. supportedTags={supportedTags}
  241. onClose={handleChange}
  242. onSearch={handleChange}
  243. placeholder={t('Filter by tags')}
  244. query={query}
  245. savedSearchType={SavedSearchType.METRIC}
  246. projectIds={projectIdNumbers}
  247. {...props}
  248. />
  249. );
  250. }
  251. function getWidgetDisplayType(
  252. mri: MetricsQuery['mri'],
  253. op: MetricsQuery['op']
  254. ): MetricDisplayType {
  255. if (mri?.startsWith('c') || op === 'count') {
  256. return MetricDisplayType.BAR;
  257. }
  258. return MetricDisplayType.LINE;
  259. }
  260. const QueryBuilderWrapper = styled('div')`
  261. display: flex;
  262. flex-grow: 1;
  263. flex-direction: column;
  264. `;
  265. const QueryBuilderRow = styled('div')`
  266. padding: ${space(1)};
  267. padding-bottom: 0;
  268. `;
  269. const WideSearchBar = styled(SearchBar)`
  270. width: 100%;
  271. opacity: ${p => (p.disabled ? '0.6' : '1')};
  272. `;
  273. const WrapPageFilterBar = styled(PageFilterBar)`
  274. max-width: max-content;
  275. height: auto;
  276. flex-wrap: wrap;
  277. `;