inlineEditor.tsx 11 KB

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