queryBuilder.tsx 9.2 KB

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