mriField.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import {Fragment, useCallback, useEffect, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import SelectControl from 'sentry/components/forms/controls/selectControl';
  4. import Tag from 'sentry/components/tag';
  5. import {t} from 'sentry/locale';
  6. import {space} from 'sentry/styles/space';
  7. import {Project} from 'sentry/types';
  8. import {parseFunction} from 'sentry/utils/discover/fields';
  9. import {getReadableMetricType, isAllowedOp, useMetricsMeta} from 'sentry/utils/metrics';
  10. interface Props {
  11. aggregate: string;
  12. onChange: (value: string, meta: Record<string, any>) => void;
  13. project: Project;
  14. }
  15. function buildAggregate(mri: string, op: string) {
  16. return `${op}(${mri})`;
  17. }
  18. export function parseAggregate(aggregate: string) {
  19. const parsedFunction = parseFunction(aggregate);
  20. if (!parsedFunction) {
  21. // We only allow aggregate functions for custom metric alerts
  22. return {
  23. mri: undefined,
  24. op: undefined,
  25. };
  26. }
  27. return {
  28. mri: parsedFunction.arguments[0],
  29. op: parsedFunction.name,
  30. };
  31. }
  32. function filterAndSortOperations(operations: string[]) {
  33. return operations.filter(isAllowedOp).sort((a, b) => a.localeCompare(b));
  34. }
  35. function MriField({aggregate, project, onChange}: Props) {
  36. const {data: meta} = useMetricsMeta([parseInt(project.id, 10)], {
  37. useCases: ['transactions', 'custom'],
  38. });
  39. const metaArr = useMemo(() => {
  40. return Object.values(meta).sort((a, b) => a.name.localeCompare(b.name));
  41. }, [meta]);
  42. const selectedValues = parseAggregate(aggregate);
  43. const selectedMriMeta = selectedValues.mri ? meta[selectedValues.mri] : null;
  44. useEffect(() => {
  45. // Auto-select the first mri if none of the available ones is selected
  46. if (!selectedMriMeta && metaArr.length > 0) {
  47. const newSelection = metaArr[0];
  48. onChange(
  49. buildAggregate(
  50. newSelection.mri,
  51. filterAndSortOperations(newSelection.operations)[0]
  52. ),
  53. {}
  54. );
  55. }
  56. }, [metaArr, onChange, selectedMriMeta]);
  57. const handleMriChange = useCallback(
  58. option => {
  59. // Make sure that the selected operation matches the new metric
  60. const availableOps = filterAndSortOperations(meta[option.value].operations);
  61. const selectedOp =
  62. selectedValues.op && availableOps.includes(selectedValues.op)
  63. ? selectedValues.op
  64. : availableOps[0];
  65. onChange(buildAggregate(option.value, selectedOp), {});
  66. },
  67. [meta, onChange, selectedValues.op]
  68. );
  69. const operationOptions = useMemo(
  70. () =>
  71. filterAndSortOperations(selectedMriMeta?.operations ?? []).map(op => ({
  72. label: op,
  73. value: op,
  74. })),
  75. [selectedMriMeta]
  76. );
  77. // As SelectControl does not support an options size limit out of the box
  78. // we work around it by using the async variant of the control
  79. const getMriOptions = useCallback(
  80. (searchText: string) => {
  81. const filteredMeta = metaArr.filter(
  82. ({name}) =>
  83. searchText === '' || name.toLowerCase().includes(searchText.toLowerCase())
  84. );
  85. const options = filteredMeta.splice(0, 100).map<{
  86. label: React.ReactNode;
  87. value: string;
  88. disabled?: boolean;
  89. trailingItems?: React.ReactNode;
  90. }>(metric => ({
  91. label: metric.name,
  92. value: metric.mri,
  93. trailingItems: (
  94. <Fragment>
  95. <Tag tooltipText={t('Type')}>{getReadableMetricType(metric.type)}</Tag>
  96. <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
  97. </Fragment>
  98. ),
  99. }));
  100. if (filteredMeta.length > options.length) {
  101. options.push({
  102. label: (
  103. <SizeLimitMessage>{t('Use search to find more options…')}</SizeLimitMessage>
  104. ),
  105. value: '',
  106. disabled: true,
  107. });
  108. }
  109. return options;
  110. },
  111. [metaArr]
  112. );
  113. // When using the async variant of SelectControl, we need to pass in an option object instead of just the value
  114. const selectedOption = selectedMriMeta && {
  115. label: selectedMriMeta.name,
  116. value: selectedMriMeta.mri,
  117. };
  118. return (
  119. <Wrapper>
  120. <StyledSelectControl
  121. searchable
  122. placeholder={t('Select a metric')}
  123. async
  124. defaultOptions={getMriOptions('')}
  125. loadOptions={searchText => Promise.resolve(getMriOptions(searchText))}
  126. filterOption={() => true}
  127. value={selectedOption}
  128. onChange={handleMriChange}
  129. />
  130. <StyledSelectControl
  131. searchable
  132. disabled={!selectedValues.mri}
  133. placeholder={t('Select an operation')}
  134. options={operationOptions}
  135. value={selectedValues.op}
  136. onChange={option => {
  137. onChange(`${option.value}(${selectedValues.mri})`, {});
  138. }}
  139. />
  140. </Wrapper>
  141. );
  142. }
  143. export default MriField;
  144. const Wrapper = styled('div')`
  145. display: grid;
  146. gap: ${space(1)};
  147. grid-template-columns: 1fr 1fr;
  148. `;
  149. const StyledSelectControl = styled(SelectControl)`
  150. width: 200px;
  151. `;
  152. const SizeLimitMessage = styled('span')`
  153. font-size: ${p => p.theme.fontSizeSmall};
  154. display: block;
  155. width: 100%;
  156. text-align: center;
  157. `;