queries.tsx 11 KB

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