queries.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. import {memo, useCallback, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import debounce from 'lodash/debounce';
  4. import {navigateTo} from 'sentry/actionCreators/navigation';
  5. import {Button} from 'sentry/components/button';
  6. import type {MenuItemProps} from 'sentry/components/dropdownMenu';
  7. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  8. import Input, {type InputProps} from 'sentry/components/input';
  9. import {CreateMetricAlertFeature} from 'sentry/components/metrics/createMetricAlertFeature';
  10. import {EquationInput} from 'sentry/components/metrics/equationInput';
  11. import {EquationSymbol} from 'sentry/components/metrics/equationSymbol';
  12. import {QueryBuilder} from 'sentry/components/metrics/queryBuilder';
  13. import {getQuerySymbol, QuerySymbol} from 'sentry/components/metrics/querySymbol';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {DEFAULT_DEBOUNCE_DURATION, SLOW_TOOLTIP_DELAY} from 'sentry/constants';
  16. import {
  17. IconAdd,
  18. IconClose,
  19. IconCopy,
  20. IconDelete,
  21. IconEdit,
  22. IconEllipsis,
  23. IconSettings,
  24. IconSiren,
  25. } from 'sentry/icons';
  26. import {t} from 'sentry/locale';
  27. import {space} from 'sentry/styles/space';
  28. import {isCustomMetric} from 'sentry/utils/metrics';
  29. import {hasMetricAlertFeature} from 'sentry/utils/metrics/features';
  30. import {MetricExpressionType} from 'sentry/utils/metrics/types';
  31. import useOrganization from 'sentry/utils/useOrganization';
  32. import usePageFilters from 'sentry/utils/usePageFilters';
  33. import useRouter from 'sentry/utils/useRouter';
  34. import type {
  35. DashboardMetricsEquation,
  36. DashboardMetricsExpression,
  37. DashboardMetricsQuery,
  38. } from 'sentry/views/dashboards/metrics/types';
  39. import {getMetricQueryName} from 'sentry/views/dashboards/metrics/utils';
  40. import {DisplayType} from 'sentry/views/dashboards/types';
  41. import {getCreateAlert} from 'sentry/views/metrics/metricQueryContextMenu';
  42. interface Props {
  43. addEquation: () => void;
  44. addQuery: (index?: number) => void;
  45. displayType: DisplayType;
  46. metricEquations: DashboardMetricsEquation[];
  47. metricQueries: DashboardMetricsQuery[];
  48. onEquationChange: (data: Partial<DashboardMetricsEquation>, index: number) => void;
  49. onQueryChange: (data: Partial<DashboardMetricsQuery>, index: number) => void;
  50. removeEquation: (index: number) => void;
  51. removeQuery: (index: number) => void;
  52. }
  53. export const Queries = memo(function Queries({
  54. displayType,
  55. metricQueries,
  56. metricEquations,
  57. onQueryChange,
  58. onEquationChange,
  59. addQuery,
  60. addEquation,
  61. removeQuery,
  62. removeEquation,
  63. }: Props) {
  64. const {selection} = usePageFilters();
  65. const availableVariables = useMemo(
  66. () => new Set(metricQueries.map(query => getQuerySymbol(query.id))),
  67. [metricQueries]
  68. );
  69. const handleEditQueryAlias = useCallback(
  70. (index: number) => {
  71. const query = metricQueries[index];
  72. const alias = getMetricQueryName(query);
  73. onQueryChange({alias}, index);
  74. },
  75. [metricQueries, onQueryChange]
  76. );
  77. const handleEditEquationAlias = useCallback(
  78. (index: number) => {
  79. const equation = metricEquations[index];
  80. const alias = getMetricQueryName(equation);
  81. onEquationChange({alias: alias ?? ''}, index);
  82. },
  83. [metricEquations, onEquationChange]
  84. );
  85. const showQuerySymbols = metricQueries.length + metricEquations.length > 1;
  86. const visibleExpressions = [...metricQueries, ...metricEquations].filter(
  87. expression => !expression.isHidden
  88. );
  89. return (
  90. <ExpressionsWrapper>
  91. {metricQueries.map((query, index) => (
  92. <ExpressionWrapper key={index}>
  93. {showQuerySymbols && (
  94. <QueryToggle
  95. isHidden={query.isHidden}
  96. onChange={isHidden => onQueryChange({isHidden}, index)}
  97. disabled={
  98. (!query.isHidden && visibleExpressions.length === 1) ||
  99. displayType === DisplayType.BIG_NUMBER
  100. }
  101. disabledReason={t('Big Number widgets support only one visible metric')}
  102. queryId={query.id}
  103. type={MetricExpressionType.QUERY}
  104. />
  105. )}
  106. <ExpressionFormWrapper>
  107. <ExpressionFormRowWrapper>
  108. <WrappedQueryBuilder
  109. index={index}
  110. onQueryChange={onQueryChange}
  111. query={query}
  112. projects={selection.projects}
  113. />
  114. <QueryContextMenu
  115. canRemoveQuery={metricQueries.length > 1}
  116. removeQuery={removeQuery}
  117. addQuery={addQuery}
  118. editAlias={handleEditQueryAlias}
  119. queryIndex={index}
  120. metricsQuery={query}
  121. />
  122. </ExpressionFormRowWrapper>
  123. {query.alias !== undefined && (
  124. <ExpressionFormRowWrapper>
  125. <ExpressionAliasForm
  126. expression={query}
  127. onChange={alias => onQueryChange({alias}, index)}
  128. hasContextMenu
  129. />
  130. </ExpressionFormRowWrapper>
  131. )}
  132. </ExpressionFormWrapper>
  133. </ExpressionWrapper>
  134. ))}
  135. {metricEquations.map((equation, index) => (
  136. <ExpressionWrapper key={index}>
  137. {showQuerySymbols && (
  138. <QueryToggle
  139. isHidden={equation.isHidden}
  140. onChange={isHidden => onEquationChange({isHidden}, index)}
  141. disabled={
  142. (!equation.isHidden && visibleExpressions.length === 1) ||
  143. displayType === DisplayType.BIG_NUMBER
  144. }
  145. disabledReason={t('Big Number widgets support only one visible metric')}
  146. queryId={equation.id}
  147. type={MetricExpressionType.EQUATION}
  148. />
  149. )}
  150. <ExpressionFormWrapper>
  151. <ExpressionFormRowWrapper>
  152. <EquationInputWrapper>
  153. <EquationInput
  154. onChange={formula => onEquationChange({formula}, index)}
  155. value={equation.formula}
  156. availableVariables={availableVariables}
  157. />
  158. </EquationInputWrapper>
  159. {equation.alias !== undefined && (
  160. <ExpressionAliasForm
  161. expression={equation}
  162. onChange={alias => onEquationChange({alias}, index)}
  163. />
  164. )}
  165. <EquationContextMenu
  166. removeEquation={removeEquation}
  167. editAlias={handleEditEquationAlias}
  168. equationIndex={index}
  169. />
  170. </ExpressionFormRowWrapper>
  171. </ExpressionFormWrapper>
  172. </ExpressionWrapper>
  173. ))}
  174. <ButtonBar addQuerySymbolSpacing={showQuerySymbols}>
  175. <Button size="sm" icon={<IconAdd isCircled />} onClick={() => addQuery()}>
  176. {t('Add metric')}
  177. </Button>
  178. {!(displayType === DisplayType.BIG_NUMBER && metricEquations.length > 0) && (
  179. <Button size="sm" icon={<IconAdd isCircled />} onClick={addEquation}>
  180. {t('Add equation')}
  181. </Button>
  182. )}
  183. </ButtonBar>
  184. </ExpressionsWrapper>
  185. );
  186. });
  187. /**
  188. * Wrapper for the QueryBuilder to memoize the onChange handler
  189. */
  190. function WrappedQueryBuilder({
  191. index,
  192. onQueryChange,
  193. projects,
  194. query,
  195. }: {
  196. index: number;
  197. onQueryChange: (data: Partial<DashboardMetricsQuery>, index: number) => void;
  198. projects: number[];
  199. query: DashboardMetricsQuery;
  200. }) {
  201. const handleChange = useCallback(
  202. (data: Partial<DashboardMetricsQuery>) => {
  203. onQueryChange(data, index);
  204. },
  205. [index, onQueryChange]
  206. );
  207. return (
  208. <QueryBuilder
  209. index={index}
  210. onChange={handleChange}
  211. metricsQuery={query}
  212. projects={projects}
  213. />
  214. );
  215. }
  216. interface QueryContextMenuProps {
  217. addQuery: (index: number) => void;
  218. canRemoveQuery: boolean;
  219. editAlias: (index: number) => void;
  220. metricsQuery: DashboardMetricsQuery;
  221. queryIndex: number;
  222. removeQuery: (index: number) => void;
  223. }
  224. function QueryContextMenu({
  225. metricsQuery,
  226. removeQuery,
  227. addQuery,
  228. canRemoveQuery,
  229. queryIndex,
  230. editAlias,
  231. }: QueryContextMenuProps) {
  232. const organization = useOrganization();
  233. const router = useRouter();
  234. const createAlert = useMemo(
  235. () => getCreateAlert(organization, metricsQuery),
  236. [metricsQuery, organization]
  237. );
  238. const items = useMemo<MenuItemProps[]>(() => {
  239. const customMetric = !isCustomMetric({mri: metricsQuery.mri});
  240. const duplicateQueryItem = {
  241. leadingItems: [<IconCopy key="icon" />],
  242. key: 'duplicate',
  243. label: t('Duplicate'),
  244. onAction: () => {
  245. addQuery(queryIndex);
  246. },
  247. };
  248. const addAlertItem = {
  249. leadingItems: [<IconSiren key="icon" />],
  250. key: 'add-alert',
  251. label: <CreateMetricAlertFeature>{t('Create Alert')}</CreateMetricAlertFeature>,
  252. disabled: !createAlert || !hasMetricAlertFeature(organization),
  253. onAction: () => {
  254. createAlert?.();
  255. },
  256. };
  257. const removeQueryItem = {
  258. leadingItems: [<IconClose key="icon" />],
  259. key: 'delete',
  260. label: t('Remove Metric'),
  261. disabled: !canRemoveQuery,
  262. onAction: () => {
  263. removeQuery(queryIndex);
  264. },
  265. };
  266. const aliasItem = {
  267. leadingItems: [<IconEdit key="icon" />],
  268. key: 'alias',
  269. label: t('Add Alias'),
  270. onAction: () => {
  271. editAlias(queryIndex);
  272. },
  273. };
  274. const settingsItem = {
  275. leadingItems: [<IconSettings key="icon" />],
  276. key: 'settings',
  277. label: t('Metric Settings'),
  278. disabled: !customMetric,
  279. onAction: () => {
  280. navigateTo(
  281. `/settings/projects/:projectId/metrics/${encodeURIComponent(metricsQuery.mri)}`,
  282. router
  283. );
  284. },
  285. };
  286. return customMetric
  287. ? [duplicateQueryItem, aliasItem, addAlertItem, removeQueryItem, settingsItem]
  288. : [duplicateQueryItem, aliasItem, addAlertItem, removeQueryItem];
  289. }, [
  290. metricsQuery.mri,
  291. createAlert,
  292. organization,
  293. canRemoveQuery,
  294. addQuery,
  295. queryIndex,
  296. removeQuery,
  297. editAlias,
  298. router,
  299. ]);
  300. return (
  301. <DropdownMenu
  302. items={items}
  303. triggerProps={{
  304. 'aria-label': t('Query actions'),
  305. size: 'md',
  306. showChevron: false,
  307. icon: <IconEllipsis direction="down" size="sm" />,
  308. }}
  309. position="bottom-end"
  310. />
  311. );
  312. }
  313. interface EquationContextMenuProps {
  314. editAlias: (index: number) => void;
  315. equationIndex: number;
  316. removeEquation: (index: number) => void;
  317. }
  318. function EquationContextMenu({
  319. equationIndex,
  320. editAlias,
  321. removeEquation,
  322. }: EquationContextMenuProps) {
  323. const items = useMemo<MenuItemProps[]>(() => {
  324. const removeEquationItem = {
  325. leadingItems: [<IconClose key="icon" />],
  326. key: 'delete',
  327. label: t('Remove Equation'),
  328. onAction: () => {
  329. removeEquation(equationIndex);
  330. },
  331. };
  332. const aliasItem = {
  333. leadingItems: [<IconEdit key="icon" />],
  334. key: 'alias',
  335. label: t('Add Alias'),
  336. onAction: () => {
  337. editAlias(equationIndex);
  338. },
  339. };
  340. return [aliasItem, removeEquationItem];
  341. }, [editAlias, equationIndex, removeEquation]);
  342. return (
  343. <DropdownMenu
  344. items={items}
  345. triggerProps={{
  346. 'aria-label': t('Equation actions'),
  347. size: 'md',
  348. showChevron: false,
  349. icon: <IconEllipsis direction="down" size="sm" />,
  350. }}
  351. position="bottom-end"
  352. />
  353. );
  354. }
  355. interface QueryToggleProps {
  356. disabled: boolean;
  357. isHidden: boolean;
  358. onChange: (isHidden: boolean) => void;
  359. queryId: number;
  360. type: MetricExpressionType;
  361. disabledReason?: string;
  362. }
  363. function QueryToggle({
  364. isHidden,
  365. queryId,
  366. disabled,
  367. onChange,
  368. type,
  369. disabledReason,
  370. }: QueryToggleProps) {
  371. const tooltipTitle =
  372. type === MetricExpressionType.QUERY
  373. ? isHidden
  374. ? t('Show metric')
  375. : t('Hide metric')
  376. : isHidden
  377. ? t('Show equation')
  378. : t('Hide equation');
  379. return (
  380. <Tooltip
  381. title={
  382. !disabled
  383. ? tooltipTitle
  384. : disabledReason || t('At least one query must be visible')
  385. }
  386. delay={500}
  387. >
  388. {type === MetricExpressionType.QUERY ? (
  389. <StyledQuerySymbol
  390. isHidden={isHidden}
  391. queryId={queryId}
  392. isClickable={!disabled}
  393. aria-disabled={disabled}
  394. onClick={disabled ? undefined : () => onChange(!isHidden)}
  395. role="button"
  396. aria-label={tooltipTitle}
  397. />
  398. ) : (
  399. <StyledEquationSymbol
  400. isHidden={isHidden}
  401. equationId={queryId}
  402. isClickable={!disabled}
  403. aria-disabled={disabled}
  404. onClick={disabled ? undefined : () => onChange(!isHidden)}
  405. role="button"
  406. aria-label={tooltipTitle}
  407. />
  408. )}
  409. </Tooltip>
  410. );
  411. }
  412. function ExpressionAliasForm({
  413. expression,
  414. onChange,
  415. hasContextMenu,
  416. }: {
  417. expression: DashboardMetricsExpression;
  418. onChange: (alias: string | undefined) => void;
  419. hasContextMenu?: boolean;
  420. }) {
  421. return (
  422. <ExpressionAliasWrapper hasOwnRow={hasContextMenu}>
  423. <StyledLabel>as</StyledLabel>
  424. <StyledDebouncedInput
  425. type="text"
  426. value={expression.alias}
  427. onChange={e => onChange(e.target.value)}
  428. placeholder={t('Add alias')}
  429. />
  430. <Tooltip title={t('Clear alias')} delay={SLOW_TOOLTIP_DELAY}>
  431. <StyledButton
  432. icon={<IconDelete size="xs" />}
  433. aria-label={t('Clear Alias')}
  434. onClick={() => onChange(undefined)}
  435. />
  436. </Tooltip>
  437. </ExpressionAliasWrapper>
  438. );
  439. }
  440. // TODO: Move this to a shared component
  441. function DebouncedInput({
  442. onChange,
  443. wait = DEFAULT_DEBOUNCE_DURATION,
  444. ...inputProps
  445. }: InputProps & {wait?: number}) {
  446. const [value, setValue] = useState<string | number | readonly string[] | undefined>(
  447. inputProps.value
  448. );
  449. const handleChange = useMemo(
  450. () =>
  451. debounce((e: React.ChangeEvent<HTMLInputElement>) => {
  452. onChange?.(e);
  453. }, wait),
  454. [onChange, wait]
  455. );
  456. return (
  457. <Input
  458. {...inputProps}
  459. value={value}
  460. onChange={e => {
  461. setValue(e.target.value);
  462. handleChange(e);
  463. }}
  464. />
  465. );
  466. }
  467. const ExpressionsWrapper = styled('div')`
  468. padding-bottom: ${space(2)};
  469. `;
  470. const ExpressionWrapper = styled('div')`
  471. display: flex;
  472. gap: ${space(1)};
  473. padding-bottom: ${space(1)};
  474. `;
  475. const ExpressionFormWrapper = styled('div')`
  476. display: flex;
  477. flex-grow: 1;
  478. flex-direction: column;
  479. gap: ${space(1)};
  480. `;
  481. const ExpressionFormRowWrapper = styled('div')`
  482. display: flex;
  483. gap: ${space(1)};
  484. `;
  485. const StyledQuerySymbol = styled(QuerySymbol)<{isClickable: boolean}>`
  486. ${p => p.isClickable && `cursor: pointer;`}
  487. `;
  488. const StyledEquationSymbol = styled(EquationSymbol)<{isClickable: boolean}>`
  489. ${p => p.isClickable && `cursor: pointer;`}
  490. `;
  491. const ButtonBar = styled('div')<{addQuerySymbolSpacing: boolean}>`
  492. align-items: center;
  493. display: flex;
  494. gap: ${space(2)};
  495. ${p =>
  496. p.addQuerySymbolSpacing &&
  497. `
  498. padding-left: ${space(1)};
  499. margin-left: 38px;
  500. `}
  501. `;
  502. const ExpressionAliasWrapper = styled('div')<{hasOwnRow?: boolean}>`
  503. display: flex;
  504. flex-basis: 50%;
  505. align-items: center;
  506. padding-bottom: ${space(1)};
  507. /* Add padding for the context menu */
  508. ${p => p.hasOwnRow && `padding-right: 56px;`}
  509. ${p => p.hasOwnRow && `flex-grow: 1;`}
  510. `;
  511. const StyledLabel = styled('div')`
  512. border: 1px solid ${p => p.theme.border};
  513. border-radius: ${p => p.theme.borderRadius};
  514. padding: ${space(1)} ${space(1.5)};
  515. color: ${p => p.theme.subText};
  516. border-top-right-radius: 0;
  517. border-bottom-right-radius: 0;
  518. border-right: none;
  519. `;
  520. const EquationInputWrapper = styled('div')`
  521. width: 100%;
  522. `;
  523. const StyledDebouncedInput = styled(DebouncedInput)`
  524. border-radius: 0;
  525. z-index: 1;
  526. `;
  527. const StyledButton = styled(Button)`
  528. border-top-left-radius: 0;
  529. border-bottom-left-radius: 0;
  530. border-left: none;
  531. `;