mriField.tsx 5.3 KB

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