queryBuilder.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. import {Fragment, memo, useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import uniqBy from 'lodash/uniqBy';
  4. import {ComboBox} from 'sentry/components/comboBox';
  5. import type {ComboBoxOption} from 'sentry/components/comboBox/types';
  6. import type {SelectOption} from 'sentry/components/compactSelect';
  7. import {CompactSelect} from 'sentry/components/compactSelect';
  8. import {Tag} from 'sentry/components/tag';
  9. import {IconLightning, IconReleases} from 'sentry/icons';
  10. import {t} from 'sentry/locale';
  11. import {space} from 'sentry/styles/space';
  12. import type {MetricMeta, MetricsOperation, MRI} from 'sentry/types';
  13. import {trackAnalytics} from 'sentry/utils/analytics';
  14. import {
  15. isAllowedOp,
  16. isCustomMetric,
  17. isSpanMeasurement,
  18. isSpanSelfTime,
  19. isTransactionDuration,
  20. isTransactionMeasurement,
  21. } from 'sentry/utils/metrics';
  22. import {hasMetricsExperimentalFeature} from 'sentry/utils/metrics/features';
  23. import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
  24. import {formatMRI, parseMRI} from 'sentry/utils/metrics/mri';
  25. import type {MetricsQuery} from 'sentry/utils/metrics/types';
  26. import {useBreakpoints} from 'sentry/utils/metrics/useBreakpoints';
  27. import {useIncrementQueryMetric} from 'sentry/utils/metrics/useIncrementQueryMetric';
  28. import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
  29. import {useMetricsTags} from 'sentry/utils/metrics/useMetricsTags';
  30. import {middleEllipsis} from 'sentry/utils/middleEllipsis';
  31. import useKeyPress from 'sentry/utils/useKeyPress';
  32. import useOrganization from 'sentry/utils/useOrganization';
  33. import usePageFilters from 'sentry/utils/usePageFilters';
  34. import {MetricSearchBar} from 'sentry/views/metrics/metricSearchBar';
  35. type QueryBuilderProps = {
  36. metricsQuery: MetricsQuery;
  37. onChange: (data: Partial<MetricsQuery>) => void;
  38. projects: number[];
  39. };
  40. const isVisibleTransactionMetric = (metric: MetricMeta) =>
  41. isTransactionDuration(metric) || isTransactionMeasurement(metric);
  42. const isVisibleSpanMetric = (metric: MetricMeta) =>
  43. isSpanSelfTime(metric) || isSpanMeasurement(metric);
  44. const isShownByDefault = (metric: MetricMeta) =>
  45. isCustomMetric(metric) ||
  46. isVisibleTransactionMetric(metric) ||
  47. isVisibleSpanMetric(metric);
  48. function getOpsForMRI(mri: MRI, meta: MetricMeta[]) {
  49. return meta.find(metric => metric.mri === mri)?.operations.filter(isAllowedOp) ?? [];
  50. }
  51. function useMriMode() {
  52. const [mriMode, setMriMode] = useState(false);
  53. const mriModeKeyPressed = useKeyPress('`', undefined, true);
  54. useEffect(() => {
  55. if (mriModeKeyPressed) {
  56. setMriMode(value => !value);
  57. }
  58. // eslint-disable-next-line react-hooks/exhaustive-deps
  59. }, [mriModeKeyPressed]);
  60. return mriMode;
  61. }
  62. export const QueryBuilder = memo(function QueryBuilder({
  63. metricsQuery,
  64. projects,
  65. onChange,
  66. }: QueryBuilderProps) {
  67. const organization = useOrganization();
  68. const pageFilters = usePageFilters();
  69. const breakpoints = useBreakpoints();
  70. const {data: meta, isLoading: isMetaLoading} = useMetricsMeta(pageFilters.selection);
  71. const mriMode = useMriMode();
  72. const shouldUseComboBox = hasMetricsExperimentalFeature(organization);
  73. const {data: tagsData = [], isLoading: tagsIsLoading} = useMetricsTags(
  74. metricsQuery.mri,
  75. {
  76. projects,
  77. }
  78. );
  79. const tags = useMemo(() => {
  80. return uniqBy(tagsData, 'key');
  81. }, [tagsData]);
  82. const displayedMetrics = useMemo(() => {
  83. const isSelected = (metric: MetricMeta) => metric.mri === metricsQuery.mri;
  84. const result = meta
  85. .filter(metric => isShownByDefault(metric) || isSelected(metric))
  86. .sort(metric => (isSelected(metric) ? -1 : 1));
  87. // Add the selected metric to the top of the list if it's not already there
  88. if (shouldUseComboBox && result[0]?.mri !== metricsQuery.mri) {
  89. const parsedMri = parseMRI(metricsQuery.mri)!;
  90. return [
  91. {
  92. mri: metricsQuery.mri,
  93. type: parsedMri.type,
  94. unit: parsedMri.unit,
  95. operations: [],
  96. },
  97. ...result,
  98. ];
  99. }
  100. return result;
  101. }, [meta, metricsQuery.mri, shouldUseComboBox]);
  102. const selectedMeta = useMemo(() => {
  103. return meta.find(metric => metric.mri === metricsQuery.mri);
  104. }, [meta, metricsQuery.mri]);
  105. const incrementQueryMetric = useIncrementQueryMetric({
  106. ...metricsQuery,
  107. });
  108. const handleMRIChange = useCallback(
  109. ({value}) => {
  110. const availableOps = getOpsForMRI(value, meta);
  111. const selectedOp = availableOps.includes(
  112. (metricsQuery.op ?? '') as MetricsOperation
  113. )
  114. ? metricsQuery.op
  115. : availableOps?.[0];
  116. const queryChanges = {
  117. mri: value,
  118. op: selectedOp,
  119. groupBy: undefined,
  120. };
  121. trackAnalytics('ddm.widget.metric', {organization});
  122. incrementQueryMetric('ddm.widget.metric', queryChanges);
  123. onChange(queryChanges);
  124. },
  125. [incrementQueryMetric, meta, metricsQuery.op, onChange, organization]
  126. );
  127. const handleOpChange = useCallback(
  128. ({value}) => {
  129. trackAnalytics('ddm.widget.operation', {organization});
  130. incrementQueryMetric('ddm.widget.operation', {op: value});
  131. onChange({
  132. op: value,
  133. });
  134. },
  135. [incrementQueryMetric, onChange, organization]
  136. );
  137. const handleGroupByChange = useCallback(
  138. (options: SelectOption<string>[]) => {
  139. trackAnalytics('ddm.widget.group', {organization});
  140. incrementQueryMetric('ddm.widget.group', {
  141. groupBy: options.map(o => o.value),
  142. });
  143. onChange({
  144. groupBy: options.map(o => o.value),
  145. });
  146. },
  147. [incrementQueryMetric, onChange, organization]
  148. );
  149. const handleQueryChange = useCallback(
  150. (query: string) => {
  151. trackAnalytics('ddm.widget.filter', {organization});
  152. incrementQueryMetric('ddm.widget.filter', {query});
  153. onChange({query});
  154. },
  155. [incrementQueryMetric, onChange, organization]
  156. );
  157. const mriOptions = useMemo(
  158. () =>
  159. displayedMetrics.map<ComboBoxOption<MRI>>(metric => ({
  160. label: mriMode ? metric.mri : formatMRI(metric.mri),
  161. // enable search by mri, name, unit (millisecond), type (c:), and readable type (counter)
  162. textValue: `${metric.mri}${getReadableMetricType(metric.type)}`,
  163. value: metric.mri,
  164. trailingItems: mriMode ? undefined : (
  165. <Fragment>
  166. <Tag tooltipText={t('Type')}>{getReadableMetricType(metric.type)}</Tag>
  167. <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
  168. </Fragment>
  169. ),
  170. })),
  171. [displayedMetrics, mriMode]
  172. );
  173. const projectIdStrings = useMemo(() => projects.map(String), [projects]);
  174. return (
  175. <QueryBuilderWrapper>
  176. <FlexBlock>
  177. {shouldUseComboBox ? (
  178. <MetricComboBox
  179. aria-label={t('Metric')}
  180. placeholder={t('Select a metric')}
  181. sizeLimit={100}
  182. size="md"
  183. isLoading={isMetaLoading}
  184. options={mriOptions}
  185. value={metricsQuery.mri}
  186. onChange={handleMRIChange}
  187. />
  188. ) : (
  189. <MetricSelect
  190. searchable
  191. sizeLimit={100}
  192. size="md"
  193. triggerLabel={middleEllipsis(
  194. formatMRI(metricsQuery.mri) ?? '',
  195. breakpoints.large ? (breakpoints.xlarge ? 70 : 45) : 30,
  196. /\.|-|_/
  197. )}
  198. options={mriOptions}
  199. value={metricsQuery.mri}
  200. onChange={handleMRIChange}
  201. shouldUseVirtualFocus
  202. />
  203. )}
  204. <FlexBlock>
  205. <OpSelect
  206. size="md"
  207. triggerProps={{prefix: t('Agg')}}
  208. options={
  209. selectedMeta?.operations.filter(isAllowedOp).map(op => ({
  210. label: op,
  211. value: op,
  212. })) ?? []
  213. }
  214. triggerLabel={metricsQuery.op}
  215. disabled={!selectedMeta}
  216. value={metricsQuery.op}
  217. onChange={handleOpChange}
  218. />
  219. <CompactSelect
  220. multiple
  221. size="md"
  222. triggerProps={{prefix: t('Group by')}}
  223. options={tags.map(tag => ({
  224. label: tag.key,
  225. value: tag.key,
  226. trailingItems: (
  227. <Fragment>
  228. {tag.key === 'release' && <IconReleases size="xs" />}
  229. {tag.key === 'transaction' && <IconLightning size="xs" />}
  230. </Fragment>
  231. ),
  232. }))}
  233. disabled={!metricsQuery.mri || tagsIsLoading}
  234. value={metricsQuery.groupBy}
  235. onChange={handleGroupByChange}
  236. />
  237. </FlexBlock>
  238. </FlexBlock>
  239. <SearchBarWrapper>
  240. <MetricSearchBar
  241. mri={metricsQuery.mri}
  242. disabled={!metricsQuery.mri}
  243. onChange={handleQueryChange}
  244. query={metricsQuery.query}
  245. projectIds={projectIdStrings}
  246. blockedTags={selectedMeta?.blockingStatus?.flatMap(s => s.blockedTags) ?? []}
  247. />
  248. </SearchBarWrapper>
  249. </QueryBuilderWrapper>
  250. );
  251. });
  252. const QueryBuilderWrapper = styled('div')`
  253. display: flex;
  254. flex-grow: 1;
  255. gap: ${space(1)};
  256. flex-wrap: wrap;
  257. `;
  258. const FlexBlock = styled('div')`
  259. display: flex;
  260. gap: ${space(1)};
  261. flex-wrap: wrap;
  262. `;
  263. const MetricComboBox = styled(ComboBox)`
  264. min-width: 200px;
  265. & > button {
  266. width: 100%;
  267. }
  268. `;
  269. const MetricSelect = styled(CompactSelect)`
  270. min-width: 200px;
  271. & > button {
  272. width: 100%;
  273. }
  274. `;
  275. const OpSelect = styled(CompactSelect)`
  276. width: 128px;
  277. min-width: min-content;
  278. & > button {
  279. width: 100%;
  280. }
  281. `;
  282. const SearchBarWrapper = styled('div')`
  283. flex: 1;
  284. min-width: 200px;
  285. `;