inlineEditor.tsx 11 KB

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