queries.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import {Fragment, useCallback, useLayoutEffect, useMemo} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import * as echarts from 'echarts/core';
  5. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  6. import {Button} from 'sentry/components/button';
  7. import {EquationInput} from 'sentry/components/metrics/equationInput';
  8. import {EquationSymbol} from 'sentry/components/metrics/equationSymbol';
  9. import {QueryBuilder} from 'sentry/components/metrics/queryBuilder';
  10. import {getQuerySymbol, QuerySymbol} from 'sentry/components/metrics/querySymbol';
  11. import SwitchButton from 'sentry/components/switchButton';
  12. import {Tooltip} from 'sentry/components/tooltip';
  13. import {IconAdd} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import {trackAnalytics} from 'sentry/utils/analytics';
  17. import {hasMetricsNewInputs} from 'sentry/utils/metrics/features';
  18. import {
  19. isMetricsQueryWidget,
  20. MetricExpressionType,
  21. type MetricsEquationWidget,
  22. type MetricsQuery,
  23. type MetricsQueryWidget,
  24. type MetricsWidget,
  25. } from 'sentry/utils/metrics/types';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import usePageFilters from 'sentry/utils/usePageFilters';
  28. import {METRIC_CHART_GROUP} from 'sentry/views/metrics/constants';
  29. import {useMetricsContext} from 'sentry/views/metrics/context';
  30. import {MetricFormulaContextMenu} from 'sentry/views/metrics/metricFormulaContextMenu';
  31. import {MetricQueryContextMenu} from 'sentry/views/metrics/metricQueryContextMenu';
  32. import {useFormulaDependencies} from 'sentry/views/metrics/utils/useFormulaDependencies';
  33. export function Queries() {
  34. const {
  35. widgets,
  36. updateWidget,
  37. setSelectedWidgetIndex,
  38. showQuerySymbols,
  39. selectedWidgetIndex,
  40. isMultiChartMode,
  41. setIsMultiChartMode,
  42. addWidget,
  43. toggleWidgetVisibility,
  44. } = useMetricsContext();
  45. const organization = useOrganization();
  46. const {selection} = usePageFilters();
  47. const formulaDependencies = useFormulaDependencies();
  48. const metricsNewInputs = hasMetricsNewInputs(organization);
  49. // Make sure all charts are connected to the same group whenever the widgets definition changes
  50. useLayoutEffect(() => {
  51. echarts.connect(METRIC_CHART_GROUP);
  52. }, [widgets]);
  53. const handleChange = useCallback(
  54. (index: number, widget: Partial<MetricsWidget>) => {
  55. updateWidget(index, widget);
  56. },
  57. [updateWidget]
  58. );
  59. const handleAddWidget = useCallback(
  60. (type: MetricExpressionType) => {
  61. trackAnalytics('ddm.widget.add', {
  62. organization,
  63. type: type === MetricExpressionType.QUERY ? 'query' : 'equation',
  64. });
  65. addWidget(type);
  66. },
  67. [addWidget, organization]
  68. );
  69. const querySymbols = useMemo(() => {
  70. const querySymbolSet = new Set<string>();
  71. for (const widget of widgets) {
  72. const symbol = getQuerySymbol(widget.id, metricsNewInputs);
  73. if (isMetricsQueryWidget(widget)) {
  74. querySymbolSet.add(symbol);
  75. }
  76. }
  77. return querySymbolSet;
  78. }, [widgets, metricsNewInputs]);
  79. const visibleWidgets = widgets.filter(widget => !widget.isHidden);
  80. return (
  81. <Fragment>
  82. <Wrapper showQuerySymbols={showQuerySymbols} hasMetricsNewInput={metricsNewInputs}>
  83. {widgets.map((widget, index) => (
  84. <Row
  85. key={`${widget.type}_${widget.id}`}
  86. onFocusCapture={() => setSelectedWidgetIndex(index)}
  87. >
  88. {isMetricsQueryWidget(widget) ? (
  89. <Query
  90. widget={widget}
  91. onChange={handleChange}
  92. onToggleVisibility={toggleWidgetVisibility}
  93. index={index}
  94. projects={selection.projects}
  95. showQuerySymbols={showQuerySymbols}
  96. isSelected={isMultiChartMode && index === selectedWidgetIndex}
  97. canBeHidden={visibleWidgets.length > 1}
  98. />
  99. ) : (
  100. <Formula
  101. availableVariables={querySymbols}
  102. onChange={handleChange}
  103. onToggleVisibility={toggleWidgetVisibility}
  104. index={index}
  105. widget={widget}
  106. showQuerySymbols={showQuerySymbols}
  107. isSelected={isMultiChartMode && index === selectedWidgetIndex}
  108. canBeHidden={visibleWidgets.length > 1}
  109. formulaDependencies={formulaDependencies}
  110. metricsNewInputs={metricsNewInputs}
  111. />
  112. )}
  113. </Row>
  114. ))}
  115. </Wrapper>
  116. <ButtonBar addQuerySymbolSpacing={showQuerySymbols}>
  117. <GuideAnchor target="add_metric_query" position="bottom">
  118. <Button
  119. size="sm"
  120. icon={<IconAdd isCircled />}
  121. onClick={() => handleAddWidget(MetricExpressionType.QUERY)}
  122. >
  123. {t('Add metric')}
  124. </Button>
  125. </GuideAnchor>
  126. <Button
  127. size="sm"
  128. icon={<IconAdd isCircled />}
  129. onClick={() => handleAddWidget(MetricExpressionType.EQUATION)}
  130. >
  131. {t('Add equation')}
  132. </Button>
  133. {widgets.length > 1 && (
  134. <SwitchWrapper>
  135. {t('One chart per metric')}
  136. <SwitchButton
  137. isActive={isMultiChartMode}
  138. toggle={() => setIsMultiChartMode(!isMultiChartMode)}
  139. />
  140. </SwitchWrapper>
  141. )}
  142. </ButtonBar>
  143. </Fragment>
  144. );
  145. }
  146. interface QueryProps {
  147. canBeHidden: boolean;
  148. index: number;
  149. isSelected: boolean;
  150. onChange: (index: number, data: Partial<MetricsWidget>) => void;
  151. onToggleVisibility: (index: number) => void;
  152. projects: number[];
  153. showQuerySymbols: boolean;
  154. widget: MetricsQueryWidget;
  155. }
  156. function Query({
  157. widget,
  158. projects,
  159. onChange,
  160. onToggleVisibility,
  161. index,
  162. isSelected,
  163. showQuerySymbols,
  164. canBeHidden,
  165. }: QueryProps) {
  166. const organizations = useOrganization();
  167. const hasMetricsNewInput = hasMetricsNewInputs(organizations);
  168. const metricsQuery = useMemo(
  169. () => ({
  170. mri: widget.mri,
  171. aggregation: widget.aggregation,
  172. condition: widget.condition,
  173. groupBy: widget.groupBy,
  174. query: widget.query,
  175. }),
  176. [widget.mri, widget.aggregation, widget.condition, widget.groupBy, widget.query]
  177. );
  178. const handleToggle = useCallback(() => {
  179. onToggleVisibility(index);
  180. }, [index, onToggleVisibility]);
  181. const handleChange = useCallback(
  182. (data: Partial<MetricsQuery>) => {
  183. onChange(index, data);
  184. },
  185. [index, onChange]
  186. );
  187. const isToggleDisabled = !canBeHidden && !widget.isHidden;
  188. return (
  189. <QueryWrapper hasSymbol={showQuerySymbols} hasMetricsNewInput={hasMetricsNewInput}>
  190. {showQuerySymbols && (
  191. <QueryToggle
  192. isHidden={widget.isHidden}
  193. disabled={isToggleDisabled}
  194. isSelected={isSelected}
  195. queryId={widget.id}
  196. onChange={handleToggle}
  197. type={MetricExpressionType.QUERY}
  198. />
  199. )}
  200. <QueryBuilder
  201. index={index}
  202. onChange={handleChange}
  203. metricsQuery={metricsQuery}
  204. projects={projects}
  205. hasSymbols={showQuerySymbols}
  206. />
  207. <MetricQueryContextMenu
  208. displayType={widget.displayType}
  209. widgetIndex={index}
  210. metricsQuery={{
  211. mri: widget.mri,
  212. query: widget.query,
  213. aggregation: widget.aggregation,
  214. condition: widget.condition,
  215. groupBy: widget.groupBy,
  216. }}
  217. />
  218. </QueryWrapper>
  219. );
  220. }
  221. interface FormulaProps {
  222. availableVariables: Set<string>;
  223. canBeHidden: boolean;
  224. formulaDependencies: ReturnType<typeof useFormulaDependencies>;
  225. index: number;
  226. isSelected: boolean;
  227. metricsNewInputs: boolean;
  228. onChange: (index: number, data: Partial<MetricsWidget>) => void;
  229. onToggleVisibility: (index: number) => void;
  230. showQuerySymbols: boolean;
  231. widget: MetricsEquationWidget;
  232. }
  233. function Formula({
  234. availableVariables,
  235. index,
  236. widget,
  237. onChange,
  238. onToggleVisibility,
  239. canBeHidden,
  240. isSelected,
  241. showQuerySymbols,
  242. formulaDependencies,
  243. metricsNewInputs,
  244. }: FormulaProps) {
  245. const organizations = useOrganization();
  246. const hasMetricsNewInput = hasMetricsNewInputs(organizations);
  247. const handleToggle = useCallback(() => {
  248. onToggleVisibility(index);
  249. }, [index, onToggleVisibility]);
  250. const handleChange = useCallback(
  251. (data: Partial<MetricsEquationWidget>) => {
  252. onChange(index, data);
  253. },
  254. [index, onChange]
  255. );
  256. const isToggleDisabled = !canBeHidden && !widget.isHidden;
  257. return (
  258. <QueryWrapper hasSymbol={showQuerySymbols} hasMetricsNewInput={hasMetricsNewInput}>
  259. {showQuerySymbols && (
  260. <QueryToggle
  261. isHidden={widget.isHidden}
  262. disabled={isToggleDisabled}
  263. isSelected={isSelected}
  264. queryId={widget.id}
  265. onChange={handleToggle}
  266. type={MetricExpressionType.EQUATION}
  267. />
  268. )}
  269. <EquationInputWrapper hasMetricsNewInput={hasMetricsNewInput}>
  270. <EquationInput
  271. availableVariables={availableVariables}
  272. value={widget.formula}
  273. onChange={formula => handleChange({formula})}
  274. metricsNewInputs={metricsNewInputs}
  275. />
  276. </EquationInputWrapper>
  277. <MetricFormulaContextMenu
  278. widgetIndex={index}
  279. formulaWidget={widget}
  280. formulaDependencies={formulaDependencies}
  281. />
  282. </QueryWrapper>
  283. );
  284. }
  285. interface QueryToggleProps {
  286. disabled: boolean;
  287. isHidden: boolean;
  288. isSelected: boolean;
  289. onChange: (isHidden: boolean) => void;
  290. queryId: number;
  291. type: MetricExpressionType;
  292. }
  293. function QueryToggle({isHidden, queryId, disabled, onChange, type}: QueryToggleProps) {
  294. const tooltipTitle =
  295. type === MetricExpressionType.QUERY
  296. ? isHidden
  297. ? t('Show metric')
  298. : t('Hide metric')
  299. : isHidden
  300. ? t('Show equation')
  301. : t('Hide equation');
  302. return (
  303. <Tooltip
  304. title={!disabled ? tooltipTitle : t('At least one query must be visible')}
  305. delay={500}
  306. >
  307. {type === MetricExpressionType.QUERY ? (
  308. <StyledQuerySymbol
  309. isHidden={isHidden}
  310. queryId={queryId}
  311. isClickable={!disabled}
  312. aria-disabled={disabled}
  313. onClick={disabled ? undefined : () => onChange(!isHidden)}
  314. role="button"
  315. aria-label={tooltipTitle}
  316. />
  317. ) : (
  318. <StyledEquationSymbol
  319. isHidden={isHidden}
  320. equationId={queryId}
  321. isClickable={!disabled}
  322. aria-disabled={disabled}
  323. onClick={disabled ? undefined : () => onChange(!isHidden)}
  324. role="button"
  325. aria-label={tooltipTitle}
  326. />
  327. )}
  328. </Tooltip>
  329. );
  330. }
  331. const QueryWrapper = styled('div')<{hasMetricsNewInput: boolean; hasSymbol: boolean}>`
  332. display: contents;
  333. ${p =>
  334. !p.hasMetricsNewInput &&
  335. css`
  336. display: grid;
  337. gap: ${space(1)};
  338. padding-bottom: ${space(1)};
  339. grid-template-columns: 1fr max-content;
  340. ${p.hasSymbol && `grid-template-columns: min-content 1fr max-content;`}
  341. `}
  342. `;
  343. const StyledQuerySymbol = styled(QuerySymbol)<{isClickable: boolean}>`
  344. cursor: not-allowed;
  345. ${p => p.isClickable && `cursor: pointer;`}
  346. `;
  347. const StyledEquationSymbol = styled(EquationSymbol)<{isClickable: boolean}>`
  348. cursor: not-allowed;
  349. ${p => p.isClickable && `cursor: pointer;`}
  350. `;
  351. const Wrapper = styled('div')<{hasMetricsNewInput: boolean; showQuerySymbols: boolean}>`
  352. ${p =>
  353. p.hasMetricsNewInput &&
  354. css`
  355. display: grid;
  356. gap: ${space(1)};
  357. grid-template-columns: ${p.showQuerySymbols
  358. ? 'min-content 1fr max-content'
  359. : '1fr max-content'};
  360. @media (min-width: ${p.theme.breakpoints.small}) {
  361. grid-template-columns: ${p.showQuerySymbols
  362. ? 'min-content 1fr 1fr max-content'
  363. : '1fr 1fr max-content'};
  364. }
  365. @media (min-width: ${p.theme.breakpoints.large}) {
  366. grid-template-columns: ${p.showQuerySymbols
  367. ? 'min-content 1fr max-content max-content max-content'
  368. : '1fr max-content max-content max-content'};
  369. }
  370. @media (min-width: ${p.theme.breakpoints.xxlarge}) {
  371. grid-template-columns: ${p.showQuerySymbols
  372. ? 'min-content max-content max-content max-content 1fr max-content'
  373. : 'max-content max-content max-content 1fr max-content'};
  374. }
  375. `}
  376. `;
  377. const EquationInputWrapper = styled('div')<{hasMetricsNewInput: boolean}>`
  378. ${p =>
  379. p.hasMetricsNewInput &&
  380. css`
  381. grid-template-columns: subgrid;
  382. grid-column-start: 2;
  383. @media (min-width: ${p.theme.breakpoints.small}) {
  384. grid-column-end: 4;
  385. }
  386. @media (min-width: ${p.theme.breakpoints.large}) {
  387. grid-column-end: 5;
  388. }
  389. @media (min-width: ${p.theme.breakpoints.xxlarge}) {
  390. grid-column-end: 6;
  391. }
  392. `}
  393. `;
  394. const Row = styled('div')`
  395. display: contents;
  396. `;
  397. const ButtonBar = styled('div')<{addQuerySymbolSpacing: boolean}>`
  398. align-items: center;
  399. display: flex;
  400. flex-wrap: wrap;
  401. padding: ${space(2)} 0;
  402. gap: ${space(2)};
  403. ${p =>
  404. p.addQuerySymbolSpacing &&
  405. `
  406. padding-left: ${space(1)};
  407. margin-left: 38px;
  408. `}
  409. `;
  410. const SwitchWrapper = styled('label')`
  411. display: flex;
  412. margin: 0;
  413. align-items: center;
  414. gap: ${space(1)};
  415. white-space: nowrap;
  416. `;