metricWidgetViewerModal.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  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 {
  7. MetricWidgetTitle,
  8. type MetricWidgetTitleState,
  9. } from 'sentry/components/modals/metricWidgetViewerModal/header';
  10. import {Queries} from 'sentry/components/modals/metricWidgetViewerModal/queries';
  11. import {MetricVisualization} from 'sentry/components/modals/metricWidgetViewerModal/visualization';
  12. import type {WidgetViewerModalOptions} from 'sentry/components/modals/widgetViewerModal';
  13. import {t} from 'sentry/locale';
  14. import type {Organization} from 'sentry/types/organization';
  15. import {getMetricsUrl} from 'sentry/utils/metrics';
  16. import {toDisplayType} from 'sentry/utils/metrics/dashboard';
  17. import {MetricExpressionType} from 'sentry/utils/metrics/types';
  18. import usePageFilters from 'sentry/utils/usePageFilters';
  19. import type {
  20. DashboardMetricsEquation,
  21. DashboardMetricsQuery,
  22. Order,
  23. } from 'sentry/views/dashboards/metrics/types';
  24. import {
  25. expressionsToWidget,
  26. getMetricEquations,
  27. getMetricQueries,
  28. getMetricWidgetTitle,
  29. useGenerateExpressionId,
  30. } from 'sentry/views/dashboards/metrics/utils';
  31. import {DisplayType} from 'sentry/views/dashboards/types';
  32. import {MetricDetails} from 'sentry/views/metrics/widgetDetails';
  33. import {OrganizationContext} from 'sentry/views/organizationContext';
  34. interface Props extends ModalRenderProps, WidgetViewerModalOptions {
  35. organization: Organization;
  36. }
  37. function MetricWidgetViewerModal({
  38. organization,
  39. widget,
  40. Footer,
  41. Body,
  42. Header,
  43. closeModal,
  44. CloseButton,
  45. onMetricWidgetEdit,
  46. dashboardFilters,
  47. }: Props) {
  48. const {selection} = usePageFilters();
  49. const [displayType, setDisplayType] = useState(widget.displayType);
  50. const [metricQueries, setMetricQueries] = useState<DashboardMetricsQuery[]>(() =>
  51. getMetricQueries(widget, dashboardFilters)
  52. );
  53. const [metricEquations, setMetricEquations] = useState<DashboardMetricsEquation[]>(() =>
  54. getMetricEquations(widget)
  55. );
  56. const filteredEquations = useMemo(
  57. () => metricEquations.filter(equation => equation.formula !== ''),
  58. [metricEquations]
  59. );
  60. const expressions = useMemo(
  61. () => [...metricQueries, ...filteredEquations],
  62. [metricQueries, filteredEquations]
  63. );
  64. const generateQueryId = useGenerateExpressionId(metricQueries);
  65. const generateEquationId = useGenerateExpressionId(metricEquations);
  66. const widgetMQL = useMemo(() => getMetricWidgetTitle(expressions), [expressions]);
  67. const [title, setTitle] = useState<MetricWidgetTitleState>({
  68. stored: widget.title,
  69. edited: widget.title,
  70. isEditing: false,
  71. });
  72. const handleTitleChange = useCallback(
  73. (patch: Partial<MetricWidgetTitleState>) => {
  74. setTitle(curr => ({...curr, ...patch}));
  75. },
  76. [setTitle]
  77. );
  78. const handleQueryChange = useCallback(
  79. (data: Partial<DashboardMetricsQuery>, index: number) => {
  80. setMetricQueries(curr => {
  81. const updated = [...curr];
  82. updated[index] = {...updated[index], ...data} as DashboardMetricsQuery;
  83. return updated;
  84. });
  85. },
  86. [setMetricQueries]
  87. );
  88. const handleEquationChange = useCallback(
  89. (data: Partial<DashboardMetricsEquation>, index: number) => {
  90. setMetricEquations(curr => {
  91. const updated = [...curr];
  92. updated[index] = {...updated[index], ...data} as DashboardMetricsEquation;
  93. return updated;
  94. });
  95. },
  96. [setMetricEquations]
  97. );
  98. const handleOrderChange = useCallback(
  99. ({id, order}: {id: number; order: Order}) => {
  100. const queryIdx = metricQueries.findIndex(query => query.id === id);
  101. if (queryIdx > -1) {
  102. setMetricQueries(curr => {
  103. return curr.map((query, i) => {
  104. const orderBy = i === queryIdx ? order : undefined;
  105. return {...query, orderBy};
  106. });
  107. });
  108. return;
  109. }
  110. const equationIdx = filteredEquations.findIndex(equation => equation.id === id);
  111. if (equationIdx > -1) {
  112. setMetricEquations(curr => {
  113. return curr.map((equation, i) => {
  114. const orderBy = i === equationIdx ? order : undefined;
  115. return {...equation, orderBy};
  116. });
  117. });
  118. }
  119. },
  120. [filteredEquations, metricQueries]
  121. );
  122. const addQuery = useCallback(
  123. (queryIndex?: number) => {
  124. setMetricQueries(curr => {
  125. const query = metricQueries[queryIndex ?? metricQueries.length - 1];
  126. return [
  127. ...(displayType === DisplayType.BIG_NUMBER
  128. ? curr.map(q => ({...q, isHidden: true}))
  129. : curr),
  130. {
  131. ...query,
  132. id: generateQueryId(),
  133. },
  134. ];
  135. });
  136. },
  137. [displayType, generateQueryId, metricQueries]
  138. );
  139. const addEquation = useCallback(() => {
  140. setMetricEquations(curr => {
  141. return [
  142. ...curr,
  143. {
  144. formula: '',
  145. name: '',
  146. id: generateEquationId(),
  147. type: MetricExpressionType.EQUATION,
  148. isHidden: false,
  149. },
  150. ];
  151. });
  152. // Hide all queries when adding an equation to a big number widget
  153. if (displayType === DisplayType.BIG_NUMBER) {
  154. setMetricQueries(curr => curr.map(q => ({...q, isHidden: true})));
  155. }
  156. }, [displayType, generateEquationId]);
  157. const removeEquation = useCallback(
  158. (index: number) => {
  159. setMetricEquations(curr => {
  160. const updated = [...curr];
  161. updated.splice(index, 1);
  162. return updated;
  163. });
  164. // Show the last query when removing an equation from a big number widget
  165. if (displayType === DisplayType.BIG_NUMBER) {
  166. setMetricQueries(curr =>
  167. curr.map((q, idx) => (idx === curr.length - 1 ? {...q, isHidden: false} : q))
  168. );
  169. }
  170. },
  171. [displayType]
  172. );
  173. const removeQuery = useCallback(
  174. (index: number) => {
  175. setMetricQueries(curr => {
  176. const updated = [...curr];
  177. updated.splice(index, 1);
  178. // Make sure the last query is visible for big number widgets
  179. if (displayType === DisplayType.BIG_NUMBER && filteredEquations.length === 0) {
  180. updated[updated.length - 1].isHidden = false;
  181. }
  182. return updated;
  183. });
  184. },
  185. [displayType, filteredEquations.length]
  186. );
  187. const handleSubmit = useCallback(() => {
  188. const convertedWidget = expressionsToWidget(
  189. [...metricQueries, ...filteredEquations],
  190. title.edited,
  191. toDisplayType(displayType),
  192. widget.interval
  193. );
  194. onMetricWidgetEdit?.({...widget, ...convertedWidget});
  195. closeModal();
  196. }, [
  197. metricQueries,
  198. filteredEquations,
  199. title.edited,
  200. displayType,
  201. onMetricWidgetEdit,
  202. closeModal,
  203. widget,
  204. ]);
  205. return (
  206. <Fragment>
  207. <OrganizationContext.Provider value={organization}>
  208. <Header>
  209. <MetricWidgetTitle
  210. title={title}
  211. onTitleChange={handleTitleChange}
  212. placeholder={widgetMQL}
  213. description={widget.description}
  214. />
  215. {/* Added a div with onClick because CloseButton overrides passed onClick handler */}
  216. <div onClick={handleSubmit}>
  217. <CloseButton />
  218. </div>
  219. </Header>
  220. <Body>
  221. <Queries
  222. displayType={displayType}
  223. metricQueries={metricQueries}
  224. metricEquations={metricEquations}
  225. onQueryChange={handleQueryChange}
  226. onEquationChange={handleEquationChange}
  227. addEquation={addEquation}
  228. addQuery={addQuery}
  229. removeEquation={removeEquation}
  230. removeQuery={removeQuery}
  231. />
  232. <MetricVisualization
  233. expressions={expressions}
  234. displayType={displayType}
  235. onDisplayTypeChange={setDisplayType}
  236. onOrderChange={handleOrderChange}
  237. interval={widget.interval}
  238. />
  239. <MetricDetails mri={metricQueries[0].mri} query={metricQueries[0].query} />
  240. </Body>
  241. <Footer>
  242. <ButtonBar gap={1}>
  243. <LinkButton
  244. to={getMetricsUrl(organization.slug, {
  245. widgets: [...metricQueries, ...metricEquations],
  246. ...selection.datetime,
  247. project: selection.projects,
  248. environment: selection.environments,
  249. })}
  250. >
  251. {t('Open in Metrics')}
  252. </LinkButton>
  253. <Button priority="primary" onClick={handleSubmit}>
  254. {t('Save changes')}
  255. </Button>
  256. </ButtonBar>
  257. </Footer>
  258. </OrganizationContext.Provider>
  259. </Fragment>
  260. );
  261. }
  262. export default MetricWidgetViewerModal;
  263. export const modalCss = css`
  264. width: 100%;
  265. max-width: 1200px;
  266. `;