mriField.tsx 5.7 KB

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