inlineEditor.tsx 11 KB

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