inlineEditor.tsx 11 KB

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