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