queryBuilder.tsx 12 KB

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