metricWidgetViewerModal.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. import {Fragment, useCallback, useMemo, useState} from 'react';
  2. import {css} from '@emotion/react';
  3. import type {ModalRenderProps} from 'sentry/actionCreators/modal';
  4. import {Button, LinkButton} from 'sentry/components/button';
  5. import ButtonBar from 'sentry/components/buttonBar';
  6. import LoadingIndicator from 'sentry/components/loadingIndicator';
  7. import {
  8. MetricWidgetTitle,
  9. type MetricWidgetTitleState,
  10. } from 'sentry/components/modals/metricWidgetViewerModal/header';
  11. import {Queries} from 'sentry/components/modals/metricWidgetViewerModal/queries';
  12. import {MetricVisualization} from 'sentry/components/modals/metricWidgetViewerModal/visualization';
  13. import type {WidgetViewerModalOptions} from 'sentry/components/modals/widgetViewerModal';
  14. import {t} from 'sentry/locale';
  15. import type {Organization} from 'sentry/types/organization';
  16. import {getMetricsUrl} from 'sentry/utils/metrics';
  17. import {toDisplayType} from 'sentry/utils/metrics/dashboard';
  18. import {hasCustomMetricsExtractionRules} from 'sentry/utils/metrics/features';
  19. import {parseMRI} from 'sentry/utils/metrics/mri';
  20. import {MetricExpressionType} from 'sentry/utils/metrics/types';
  21. import {
  22. useVirtualMetricsContext,
  23. VirtualMetricsContextProvider,
  24. } from 'sentry/utils/metrics/virtualMetricsContext';
  25. import usePageFilters from 'sentry/utils/usePageFilters';
  26. import type {
  27. DashboardMetricsEquation,
  28. DashboardMetricsQuery,
  29. Order,
  30. } from 'sentry/views/dashboards/metrics/types';
  31. import {
  32. expressionsToWidget,
  33. getMetricEquations,
  34. getMetricQueries,
  35. getMetricWidgetTitle,
  36. useGenerateExpressionId,
  37. } from 'sentry/views/dashboards/metrics/utils';
  38. import {DisplayType} from 'sentry/views/dashboards/types';
  39. import {MetricDetails} from 'sentry/views/metrics/widgetDetails';
  40. import {OrganizationContext} from 'sentry/views/organizationContext';
  41. interface Props extends ModalRenderProps, WidgetViewerModalOptions {
  42. organization: Organization;
  43. }
  44. function MetricWidgetViewerModal({
  45. organization,
  46. widget,
  47. Footer,
  48. Body,
  49. Header,
  50. closeModal,
  51. CloseButton,
  52. onMetricWidgetEdit,
  53. dashboardFilters,
  54. }: Props) {
  55. const {selection} = usePageFilters();
  56. const {resolveVirtualMRI, getVirtualMRIQuery, isLoading} = useVirtualMetricsContext();
  57. const [userHasModified, setUserHasModified] = useState(false);
  58. const [displayType, setDisplayType] = useState(widget.displayType);
  59. const [metricQueries, setMetricQueries] = useState<DashboardMetricsQuery[]>(() =>
  60. getMetricQueries(widget, dashboardFilters, getVirtualMRIQuery)
  61. );
  62. const [metricEquations, setMetricEquations] = useState<DashboardMetricsEquation[]>(() =>
  63. getMetricEquations(widget)
  64. );
  65. const filteredEquations = useMemo(
  66. () => metricEquations.filter(equation => equation.formula !== ''),
  67. [metricEquations]
  68. );
  69. const expressions = useMemo(
  70. () => [...metricQueries, ...filteredEquations],
  71. [metricQueries, filteredEquations]
  72. );
  73. const generateQueryId = useGenerateExpressionId(metricQueries);
  74. const generateEquationId = useGenerateExpressionId(metricEquations);
  75. const widgetMQL = useMemo(() => getMetricWidgetTitle(expressions), [expressions]);
  76. const [title, setTitle] = useState<MetricWidgetTitleState>({
  77. stored: widget.title,
  78. edited: widget.title,
  79. isEditing: false,
  80. });
  81. const handleTitleChange = useCallback(
  82. (patch: Partial<MetricWidgetTitleState>) => {
  83. setTitle(curr => ({...curr, ...patch}));
  84. setUserHasModified(true);
  85. },
  86. [setTitle]
  87. );
  88. const handleQueryChange = useCallback(
  89. (data: Partial<DashboardMetricsQuery>, index: number) => {
  90. setMetricQueries(curr => {
  91. const updated = [...curr];
  92. updated[index] = {...updated[index], ...data} as DashboardMetricsQuery;
  93. return updated;
  94. });
  95. setUserHasModified(true);
  96. },
  97. [setMetricQueries]
  98. );
  99. const handleEquationChange = useCallback(
  100. (data: Partial<DashboardMetricsEquation>, index: number) => {
  101. setMetricEquations(curr => {
  102. const updated = [...curr];
  103. updated[index] = {...updated[index], ...data} as DashboardMetricsEquation;
  104. return updated;
  105. });
  106. setUserHasModified(true);
  107. },
  108. [setMetricEquations]
  109. );
  110. const handleOrderChange = useCallback(
  111. ({id, order}: {id: number; order: Order}) => {
  112. setUserHasModified(true);
  113. const queryIdx = metricQueries.findIndex(query => query.id === id);
  114. if (queryIdx > -1) {
  115. setMetricQueries(curr => {
  116. return curr.map((query, i) => {
  117. const orderBy = i === queryIdx ? order : undefined;
  118. return {...query, orderBy};
  119. });
  120. });
  121. return;
  122. }
  123. const equationIdx = filteredEquations.findIndex(equation => equation.id === id);
  124. if (equationIdx > -1) {
  125. setMetricEquations(curr => {
  126. return curr.map((equation, i) => {
  127. const orderBy = i === equationIdx ? order : undefined;
  128. return {...equation, orderBy};
  129. });
  130. });
  131. }
  132. },
  133. [filteredEquations, metricQueries]
  134. );
  135. const addQuery = useCallback(
  136. (queryIndex?: number) => {
  137. setMetricQueries(curr => {
  138. const query = metricQueries[queryIndex ?? metricQueries.length - 1];
  139. return [
  140. ...(displayType === DisplayType.BIG_NUMBER
  141. ? curr.map(q => ({...q, isHidden: true}))
  142. : curr),
  143. {
  144. ...query,
  145. id: generateQueryId(),
  146. },
  147. ];
  148. });
  149. setUserHasModified(true);
  150. },
  151. [displayType, generateQueryId, metricQueries]
  152. );
  153. const addEquation = useCallback(() => {
  154. setMetricEquations(curr => {
  155. return [
  156. ...curr,
  157. {
  158. formula: '',
  159. name: '',
  160. id: generateEquationId(),
  161. type: MetricExpressionType.EQUATION,
  162. isHidden: false,
  163. },
  164. ];
  165. });
  166. // Hide all queries when adding an equation to a big number widget
  167. if (displayType === DisplayType.BIG_NUMBER) {
  168. setMetricQueries(curr => curr.map(q => ({...q, isHidden: true})));
  169. }
  170. setUserHasModified(true);
  171. }, [displayType, generateEquationId]);
  172. const removeEquation = useCallback(
  173. (index: number) => {
  174. setMetricEquations(curr => {
  175. const updated = [...curr];
  176. updated.splice(index, 1);
  177. return updated;
  178. });
  179. // Show the last query when removing an equation from a big number widget
  180. if (displayType === DisplayType.BIG_NUMBER) {
  181. setMetricQueries(curr =>
  182. curr.map((q, idx) => (idx === curr.length - 1 ? {...q, isHidden: false} : q))
  183. );
  184. }
  185. setUserHasModified(true);
  186. },
  187. [displayType]
  188. );
  189. const removeQuery = useCallback(
  190. (index: number) => {
  191. setMetricQueries(curr => {
  192. const updated = [...curr];
  193. updated.splice(index, 1);
  194. // Make sure the last query is visible for big number widgets
  195. if (displayType === DisplayType.BIG_NUMBER && filteredEquations.length === 0) {
  196. updated[updated.length - 1].isHidden = false;
  197. }
  198. return updated;
  199. });
  200. setUserHasModified(true);
  201. },
  202. [displayType, filteredEquations.length]
  203. );
  204. const handleSubmit = useCallback(() => {
  205. const resolvedQueries = metricQueries.map(query => {
  206. const {type} = parseMRI(query.mri);
  207. if (type !== 'v' || !query.condition) {
  208. return query;
  209. }
  210. const {mri, aggregation} = resolveVirtualMRI(
  211. query.mri,
  212. query.condition,
  213. query.aggregation
  214. );
  215. return {
  216. ...query,
  217. mri,
  218. aggregation,
  219. };
  220. });
  221. const convertedWidget = expressionsToWidget(
  222. [...resolvedQueries, ...filteredEquations],
  223. title.edited,
  224. toDisplayType(displayType),
  225. widget.interval
  226. );
  227. onMetricWidgetEdit?.({...widget, ...convertedWidget});
  228. closeModal();
  229. }, [
  230. metricQueries,
  231. filteredEquations,
  232. title.edited,
  233. displayType,
  234. widget,
  235. onMetricWidgetEdit,
  236. closeModal,
  237. resolveVirtualMRI,
  238. ]);
  239. const handleDisplayTypeChange = useCallback((type: DisplayType) => {
  240. setDisplayType(type);
  241. setUserHasModified(true);
  242. }, []);
  243. const handleClose = useCallback(() => {
  244. if (
  245. userHasModified &&
  246. // eslint-disable-next-line no-alert
  247. !window.confirm(t('You have unsaved changes, are you sure you want to close?'))
  248. ) {
  249. return;
  250. }
  251. closeModal();
  252. }, [userHasModified, closeModal]);
  253. const {mri, aggregation, query, condition} = metricQueries[0];
  254. if (isLoading) {
  255. return <LoadingIndicator />;
  256. }
  257. return (
  258. <Fragment>
  259. <OrganizationContext.Provider value={organization}>
  260. <Header>
  261. <MetricWidgetTitle
  262. title={title}
  263. onTitleChange={handleTitleChange}
  264. placeholder={widgetMQL}
  265. description={widget.description}
  266. />
  267. <CloseButton onClick={handleClose} />
  268. </Header>
  269. <Body>
  270. <Queries
  271. displayType={displayType}
  272. metricQueries={metricQueries}
  273. metricEquations={metricEquations}
  274. onQueryChange={handleQueryChange}
  275. onEquationChange={handleEquationChange}
  276. addEquation={addEquation}
  277. addQuery={addQuery}
  278. removeEquation={removeEquation}
  279. removeQuery={removeQuery}
  280. />
  281. <MetricVisualization
  282. expressions={expressions}
  283. displayType={displayType}
  284. onDisplayTypeChange={handleDisplayTypeChange}
  285. onOrderChange={handleOrderChange}
  286. interval={widget.interval}
  287. />
  288. <MetricDetails
  289. mri={mri}
  290. aggregation={aggregation}
  291. condition={condition}
  292. query={query}
  293. />
  294. </Body>
  295. <Footer>
  296. <ButtonBar gap={1}>
  297. <LinkButton
  298. to={getMetricsUrl(organization.slug, {
  299. widgets: [...metricQueries, ...metricEquations],
  300. ...selection.datetime,
  301. project: selection.projects,
  302. environment: selection.environments,
  303. })}
  304. >
  305. {t('Open in Metrics')}
  306. </LinkButton>
  307. <Button priority="primary" onClick={handleSubmit}>
  308. {t('Save changes')}
  309. </Button>
  310. </ButtonBar>
  311. </Footer>
  312. </OrganizationContext.Provider>
  313. </Fragment>
  314. );
  315. }
  316. function WrappedMetricWidgetViewerModal(props: Props) {
  317. return hasCustomMetricsExtractionRules(props.organization) ? (
  318. <VirtualMetricsContextProvider>
  319. <MetricWidgetViewerModal {...props} />
  320. </VirtualMetricsContextProvider>
  321. ) : (
  322. <MetricWidgetViewerModal {...props} />
  323. );
  324. }
  325. export default WrappedMetricWidgetViewerModal;
  326. export const modalCss = css`
  327. width: 100%;
  328. max-width: 1200px;
  329. `;