queryBuilder.tsx 9.9 KB

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