queryBuilder.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import {Fragment, memo, useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {SelectOption} from 'sentry/components/compactSelect';
  4. import {CompactSelect} from 'sentry/components/compactSelect';
  5. import Tag from 'sentry/components/tag';
  6. import {IconLightning, IconReleases} from 'sentry/icons';
  7. import {t} from 'sentry/locale';
  8. import {space} from 'sentry/styles/space';
  9. import type {MetricMeta, MetricsOperation, MRI} from 'sentry/types';
  10. import {trackAnalytics} from 'sentry/utils/analytics';
  11. import {
  12. getDefaultMetricDisplayType,
  13. isAllowedOp,
  14. isCustomMetric,
  15. isMeasurement,
  16. isSpanMetric,
  17. isTransactionDuration,
  18. } from 'sentry/utils/metrics';
  19. import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
  20. import {formatMRI} from 'sentry/utils/metrics/mri';
  21. import type {
  22. MetricDisplayType,
  23. MetricsQuerySubject,
  24. MetricWidgetQueryParams,
  25. } 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/ddm/metricSearchBar';
  35. type QueryBuilderProps = {
  36. displayType: MetricDisplayType;
  37. isEdit: boolean;
  38. metricsQuery: MetricsQuerySubject;
  39. onChange: (data: Partial<MetricWidgetQueryParams>) => void;
  40. projects: number[];
  41. fixedWidth?: boolean;
  42. powerUserMode?: boolean;
  43. };
  44. const isShownByDefault = (metric: MetricMeta) =>
  45. isCustomMetric(metric) ||
  46. isTransactionDuration(metric) ||
  47. isMeasurement(metric) ||
  48. isSpanMetric(metric);
  49. function getOpsForMRI(mri: MRI, meta: MetricMeta[]) {
  50. return meta.find(metric => metric.mri === mri)?.operations.filter(isAllowedOp) ?? [];
  51. }
  52. export const QueryBuilder = memo(function QueryBuilder({
  53. metricsQuery,
  54. projects,
  55. displayType,
  56. powerUserMode,
  57. onChange,
  58. }: QueryBuilderProps) {
  59. const organization = useOrganization();
  60. const pageFilters = usePageFilters();
  61. const {data: meta} = useMetricsMeta(pageFilters.selection);
  62. const mriModeKeyPressed = useKeyPress('`', undefined, true);
  63. const [mriMode, setMriMode] = useState(powerUserMode); // power user mode that shows raw MRI instead of metrics names
  64. const breakpoints = useBreakpoints();
  65. useEffect(() => {
  66. if (mriModeKeyPressed && !powerUserMode) {
  67. setMriMode(!mriMode);
  68. }
  69. // eslint-disable-next-line react-hooks/exhaustive-deps
  70. }, [mriModeKeyPressed, powerUserMode]);
  71. const {data: tags = [], isLoading: tagsIsLoading} = useMetricsTags(metricsQuery.mri, {
  72. projects,
  73. });
  74. const displayedMetrics = useMemo(() => {
  75. if (mriMode) {
  76. return meta;
  77. }
  78. const isSelected = (metric: MetricMeta) => metric.mri === metricsQuery.mri;
  79. return meta
  80. .filter(metric => isShownByDefault(metric) || isSelected(metric))
  81. .sort(metric => (isSelected(metric) ? -1 : 1));
  82. }, [meta, metricsQuery.mri, mriMode]);
  83. const selectedMeta = useMemo(() => {
  84. return meta.find(metric => metric.mri === metricsQuery.mri);
  85. }, [meta, metricsQuery.mri]);
  86. const incrementQueryMetric = useIncrementQueryMetric({
  87. displayType,
  88. op: metricsQuery.op,
  89. groupBy: metricsQuery.groupBy,
  90. query: metricsQuery.query,
  91. mri: metricsQuery.mri,
  92. });
  93. const handleMRIChange = useCallback(
  94. ({value}) => {
  95. const availableOps = getOpsForMRI(value, meta);
  96. const selectedOp = availableOps.includes(
  97. (metricsQuery.op ?? '') as MetricsOperation
  98. )
  99. ? metricsQuery.op
  100. : availableOps?.[0];
  101. const queryChanges = {
  102. mri: value,
  103. op: selectedOp,
  104. groupBy: undefined,
  105. displayType: getDefaultMetricDisplayType(value, selectedOp),
  106. };
  107. trackAnalytics('ddm.widget.metric', {organization});
  108. incrementQueryMetric('ddm.widget.metric', queryChanges);
  109. onChange({
  110. ...queryChanges,
  111. focusedSeries: undefined,
  112. });
  113. },
  114. [incrementQueryMetric, meta, metricsQuery.op, onChange, organization]
  115. );
  116. const handleOpChange = useCallback(
  117. ({value}) => {
  118. trackAnalytics('ddm.widget.operation', {organization});
  119. incrementQueryMetric('ddm.widget.operation', {op: value});
  120. onChange({
  121. op: value,
  122. });
  123. },
  124. [incrementQueryMetric, onChange, organization]
  125. );
  126. const handleGroupByChange = useCallback(
  127. (options: SelectOption<string>[]) => {
  128. trackAnalytics('ddm.widget.group', {organization});
  129. incrementQueryMetric('ddm.widget.group', {
  130. groupBy: options.map(o => o.value),
  131. });
  132. onChange({
  133. groupBy: options.map(o => o.value),
  134. focusedSeries: undefined,
  135. });
  136. },
  137. [incrementQueryMetric, onChange, organization]
  138. );
  139. const handleQueryChange = useCallback(
  140. (query: string) => {
  141. trackAnalytics('ddm.widget.filter', {organization});
  142. incrementQueryMetric('ddm.widget.filter', {query});
  143. onChange({query});
  144. },
  145. [incrementQueryMetric, onChange, organization]
  146. );
  147. const mriOptions = useMemo(
  148. () =>
  149. displayedMetrics.map<SelectOption<MRI>>(metric => ({
  150. label: mriMode ? metric.mri : formatMRI(metric.mri),
  151. // enable search by mri, name, unit (millisecond), type (c:), and readable type (counter)
  152. textValue: `${metric.mri}${getReadableMetricType(metric.type)}`,
  153. value: metric.mri,
  154. trailingItems: mriMode ? undefined : (
  155. <Fragment>
  156. <Tag tooltipText={t('Type')}>{getReadableMetricType(metric.type)}</Tag>
  157. <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
  158. </Fragment>
  159. ),
  160. })),
  161. [displayedMetrics, mriMode]
  162. );
  163. return (
  164. <QueryBuilderWrapper>
  165. <FlexBlock>
  166. <MetricSelect
  167. searchable
  168. sizeLimit={100}
  169. size="md"
  170. triggerLabel={middleEllipsis(
  171. formatMRI(metricsQuery.mri) ?? '',
  172. breakpoints.large ? (breakpoints.xlarge ? 70 : 45) : 30,
  173. /\.|-|_/
  174. )}
  175. placeholder={t('Select a metric')}
  176. options={mriOptions}
  177. value={metricsQuery.mri}
  178. onChange={handleMRIChange}
  179. />
  180. <FlexBlock>
  181. <OpSelect
  182. size="md"
  183. triggerProps={{prefix: t('Op')}}
  184. options={
  185. selectedMeta?.operations.filter(isAllowedOp).map(op => ({
  186. label: op,
  187. value: op,
  188. })) ?? []
  189. }
  190. disabled={!metricsQuery.mri}
  191. value={metricsQuery.op}
  192. onChange={handleOpChange}
  193. />
  194. <CompactSelect
  195. multiple
  196. size="md"
  197. triggerProps={{prefix: t('Group by')}}
  198. options={tags.map(tag => ({
  199. label: tag.key,
  200. value: tag.key,
  201. trailingItems: (
  202. <Fragment>
  203. {tag.key === 'release' && <IconReleases size="xs" />}
  204. {tag.key === 'transaction' && <IconLightning size="xs" />}
  205. </Fragment>
  206. ),
  207. }))}
  208. disabled={!metricsQuery.mri || tagsIsLoading}
  209. value={metricsQuery.groupBy}
  210. onChange={handleGroupByChange}
  211. />
  212. </FlexBlock>
  213. </FlexBlock>
  214. <SearchBarWrapper>
  215. <MetricSearchBar
  216. mri={metricsQuery.mri}
  217. disabled={!metricsQuery.mri}
  218. onChange={handleQueryChange}
  219. query={metricsQuery.query}
  220. />
  221. </SearchBarWrapper>
  222. </QueryBuilderWrapper>
  223. );
  224. });
  225. const QueryBuilderWrapper = styled('div')`
  226. display: flex;
  227. flex-grow: 1;
  228. gap: ${space(1)};
  229. flex-wrap: wrap;
  230. `;
  231. const FlexBlock = styled('div')`
  232. display: flex;
  233. gap: ${space(1)};
  234. flex-wrap: wrap;
  235. `;
  236. const MetricSelect = styled(CompactSelect)`
  237. min-width: 200px;
  238. & > button {
  239. width: 100%;
  240. }
  241. `;
  242. const OpSelect = styled(CompactSelect)`
  243. width: 120px;
  244. & > button {
  245. width: 100%;
  246. }
  247. `;
  248. const SearchBarWrapper = styled('div')`
  249. flex: 1;
  250. min-width: 200px;
  251. `;