queries.tsx 17 KB

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