inlineEditor.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import {Fragment, memo, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button, ButtonProps} from 'sentry/components/button';
  4. import {CompactSelect} from 'sentry/components/compactSelect';
  5. import LoadingIndicator from 'sentry/components/loadingIndicator';
  6. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  7. import Tag from 'sentry/components/tag';
  8. import {IconCheckmark, IconClose, IconLightning, IconReleases} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import {MetricMeta, MRI} from 'sentry/types';
  12. import {
  13. getReadableMetricType,
  14. isAllowedOp,
  15. isCustomMetric,
  16. isMeasurement,
  17. isTransactionDuration,
  18. MetricDisplayType,
  19. MetricsQuery,
  20. MetricsQuerySubject,
  21. MetricWidgetQueryParams,
  22. } from 'sentry/utils/metrics';
  23. import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
  24. import {useMetricsTags} from 'sentry/utils/metrics/useMetricsTags';
  25. import {MetricSearchBar} from 'sentry/views/ddm/metricSearchBar';
  26. import {formatMRI} from '../../../../utils/metrics/mri';
  27. type InlineEditorProps = {
  28. displayType: MetricDisplayType;
  29. isEdit: boolean;
  30. metricsQuery: MetricsQuerySubject;
  31. onCancel: () => void;
  32. onChange: (data: Partial<MetricWidgetQueryParams>) => void;
  33. onSubmit: () => void;
  34. projects: number[];
  35. powerUserMode?: boolean;
  36. size?: 'xs' | 'sm';
  37. };
  38. const isShownByDefault = (metric: MetricMeta) =>
  39. isMeasurement(metric) || isCustomMetric(metric) || isTransactionDuration(metric);
  40. export const InlineEditor = memo(function InlineEditor({
  41. metricsQuery,
  42. projects,
  43. displayType,
  44. onChange,
  45. onCancel,
  46. onSubmit,
  47. isEdit,
  48. size = 'sm',
  49. }: InlineEditorProps) {
  50. const {data: meta, isLoading: isMetaLoading} = useMetricsMeta(projects);
  51. const {data: tags = []} = useMetricsTags(metricsQuery.mri, projects);
  52. const displayedMetrics = useMemo(() => {
  53. const isSelected = (metric: MetricMeta) => metric.mri === metricsQuery.mri;
  54. return meta
  55. .filter(metric => isShownByDefault(metric) || isSelected(metric))
  56. .sort(metric => (isSelected(metric) ? -1 : 1));
  57. }, [meta, metricsQuery.mri]);
  58. const selectedMeta = useMemo(() => {
  59. return meta.find(metric => metric.mri === metricsQuery.mri);
  60. }, [meta, metricsQuery.mri]);
  61. // Reset the query data if the selected metric is no longer available
  62. useEffect(() => {
  63. if (
  64. metricsQuery.mri &&
  65. !isMetaLoading &&
  66. !displayedMetrics.find(metric => metric.mri === metricsQuery.mri)
  67. ) {
  68. onChange({mri: '' as MRI, op: '', groupBy: []});
  69. }
  70. }, [isMetaLoading, displayedMetrics, metricsQuery.mri, onChange]);
  71. const [loading, setIsLoading] = useState(false);
  72. useEffect(() => {
  73. if (loading && !isEdit) {
  74. setIsLoading(false);
  75. }
  76. }, [isEdit, loading]);
  77. return (
  78. <InlineEditorWrapper>
  79. <InlineEditorRowsWrapper>
  80. <InlineEditorRow>
  81. <WrapPageFilterBar>
  82. <CompactSelect
  83. size={size}
  84. searchable
  85. sizeLimit={100}
  86. triggerProps={{prefix: t('Metric'), size}}
  87. options={displayedMetrics.map(metric => ({
  88. label: formatMRI(metric.mri),
  89. // enable search by mri, name, unit (millisecond), type (c:), and readable type (counter)
  90. textValue: `${metric.mri}${getReadableMetricType(metric.type)}`,
  91. value: metric.mri,
  92. size,
  93. trailingItems: () => (
  94. <Fragment>
  95. <TagWithSize size={size} tooltipText={t('Type')}>
  96. {getReadableMetricType(metric.type)}
  97. </TagWithSize>
  98. <TagWithSize size={size} tooltipText={t('Unit')}>
  99. {metric.unit}
  100. </TagWithSize>
  101. </Fragment>
  102. ),
  103. }))}
  104. value={metricsQuery.mri}
  105. onChange={option => {
  106. const availableOps =
  107. meta
  108. .find(metric => metric.mri === option.value)
  109. ?.operations.filter(isAllowedOp) ?? [];
  110. // @ts-expect-error .op is an operation
  111. const selectedOp = availableOps.includes(metricsQuery.op ?? '')
  112. ? metricsQuery.op
  113. : availableOps?.[0];
  114. onChange({
  115. mri: option.value,
  116. op: selectedOp,
  117. groupBy: undefined,
  118. focusedSeries: undefined,
  119. displayType: getWidgetDisplayType(option.value, selectedOp),
  120. });
  121. }}
  122. />
  123. <CompactSelect
  124. size={size}
  125. triggerProps={{prefix: t('Op'), size}}
  126. options={
  127. selectedMeta?.operations.filter(isAllowedOp).map(op => ({
  128. label: op,
  129. value: op,
  130. })) ?? []
  131. }
  132. disabled={!metricsQuery.mri}
  133. value={metricsQuery.op}
  134. onChange={option => {
  135. onChange({
  136. op: option.value,
  137. });
  138. }}
  139. />
  140. <CompactSelect
  141. size={size}
  142. multiple
  143. triggerProps={{prefix: t('Group by'), size}}
  144. options={tags.map(tag => ({
  145. label: tag.key,
  146. value: tag.key,
  147. size,
  148. trailingItems: (
  149. <Fragment>
  150. {tag.key === 'release' && <IconReleases size={size} />}
  151. {tag.key === 'transaction' && <IconLightning size={size} />}
  152. </Fragment>
  153. ),
  154. }))}
  155. disabled={!metricsQuery.mri}
  156. value={metricsQuery.groupBy}
  157. onChange={options => {
  158. onChange({
  159. groupBy: options.map(o => o.value),
  160. focusedSeries: undefined,
  161. });
  162. }}
  163. />
  164. <CompactSelect
  165. size={size}
  166. triggerProps={{prefix: t('Display'), size}}
  167. value={displayType}
  168. options={[
  169. {
  170. value: MetricDisplayType.LINE,
  171. label: t('Line'),
  172. },
  173. {
  174. value: MetricDisplayType.AREA,
  175. label: t('Area'),
  176. },
  177. {
  178. value: MetricDisplayType.BAR,
  179. label: t('Bar'),
  180. },
  181. ]}
  182. onChange={({value}) => {
  183. onChange({displayType: value});
  184. }}
  185. />
  186. </WrapPageFilterBar>
  187. </InlineEditorRow>
  188. <InlineEditorRow>
  189. <MetricSearchBar
  190. projectIds={projects.map(id => id.toString())}
  191. mri={metricsQuery.mri}
  192. disabled={!metricsQuery.mri}
  193. onChange={query => {
  194. onChange({query});
  195. }}
  196. query={metricsQuery.query}
  197. />
  198. </InlineEditorRow>
  199. </InlineEditorRowsWrapper>
  200. <InlineEditorRowsWrapper>
  201. <SubmitButton
  202. size={size}
  203. loading={loading}
  204. onClick={() => {
  205. onSubmit();
  206. setIsLoading(true);
  207. }}
  208. aria-label="apply"
  209. />
  210. <Button
  211. size={size}
  212. onClick={onCancel}
  213. icon={<IconClose size="xs" />}
  214. aria-label="cancel"
  215. />
  216. </InlineEditorRowsWrapper>
  217. </InlineEditorWrapper>
  218. );
  219. });
  220. function SubmitButton({loading, ...buttonProps}: {loading: boolean} & ButtonProps) {
  221. if (loading) {
  222. return (
  223. <LoadingIndicatorButton {...buttonProps} priority="primary">
  224. <LoadingIndicator mini size={20} />
  225. </LoadingIndicatorButton>
  226. );
  227. }
  228. return (
  229. <Button {...buttonProps} priority="primary" icon={<IconCheckmark size="xs" />} />
  230. );
  231. }
  232. function getWidgetDisplayType(
  233. mri: MetricsQuery['mri'],
  234. op: MetricsQuery['op']
  235. ): MetricDisplayType {
  236. if (mri?.startsWith('c') || op === 'count') {
  237. return MetricDisplayType.BAR;
  238. }
  239. return MetricDisplayType.LINE;
  240. }
  241. function TagWithSize({size, children, ...props}: {size: 'sm' | 'xs'} & any) {
  242. if (size === 'sm') {
  243. return <Tag {...props}>{children}</Tag>;
  244. }
  245. return <TagXS {...props}>{children}</TagXS>;
  246. }
  247. const TagXS = styled(Tag)`
  248. font-size: ${p => p.theme.fontSizeExtraSmall};
  249. height: ${space(2)};
  250. line-height: ${space(2)};
  251. span,
  252. div {
  253. height: ${space(2)};
  254. line-height: ${space(2)};
  255. }
  256. `;
  257. const LoadingIndicatorButton = styled(Button)`
  258. padding: 0;
  259. padding-left: ${space(0.75)};
  260. pointer-events: none;
  261. div.loading.mini {
  262. height: ${space(3)};
  263. width: 26px;
  264. }
  265. `;
  266. const InlineEditorWrapper = styled('div')`
  267. display: flex;
  268. flex-grow: 1;
  269. flex-direction: row;
  270. padding: ${space(1)};
  271. gap: ${space(1)};
  272. z-index: 1;
  273. `;
  274. const InlineEditorRowsWrapper = styled('div')`
  275. display: flex;
  276. flex-direction: column;
  277. gap: ${space(0.5)};
  278. `;
  279. const InlineEditorRow = styled('div')`
  280. padding-bottom: ${space(0.5)};
  281. background: ${p => p.theme.background};
  282. `;
  283. const WrapPageFilterBar = styled(PageFilterBar)`
  284. max-width: max-content;
  285. height: auto;
  286. flex-wrap: wrap;
  287. `;