context.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import {
  2. createContext,
  3. useCallback,
  4. useContext,
  5. useEffect,
  6. useMemo,
  7. useState,
  8. } from 'react';
  9. import * as Sentry from '@sentry/react';
  10. import isEqual from 'lodash/isEqual';
  11. import type {Field} from 'sentry/components/metrics/metricSamplesTable';
  12. import {useInstantRef, useUpdateQuery} from 'sentry/utils/metrics';
  13. import {
  14. emptyMetricsFormulaWidget,
  15. emptyMetricsQueryWidget,
  16. NO_QUERY_ID,
  17. } from 'sentry/utils/metrics/constants';
  18. import {MetricExpressionType, type MetricsWidget} from 'sentry/utils/metrics/types';
  19. import type {MetricsSamplesResults} from 'sentry/utils/metrics/useMetricsSamples';
  20. import {decodeInteger, decodeScalar} from 'sentry/utils/queryString';
  21. import useLocationQuery from 'sentry/utils/url/useLocationQuery';
  22. import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
  23. import usePageFilters from 'sentry/utils/usePageFilters';
  24. import useProjects from 'sentry/utils/useProjects';
  25. import useRouter from 'sentry/utils/useRouter';
  26. import type {FocusAreaSelection} from 'sentry/views/metrics/chart/types';
  27. import {parseMetricWidgetsQueryParam} from 'sentry/views/metrics/utils/parseMetricWidgetsQueryParam';
  28. import {useStructuralSharing} from 'sentry/views/metrics/utils/useStructuralSharing';
  29. export type FocusAreaProps = {
  30. onAdd?: (area: FocusAreaSelection) => void;
  31. onDraw?: () => void;
  32. onRemove?: () => void;
  33. selection?: FocusAreaSelection;
  34. };
  35. interface MetricsContextValue {
  36. addWidget: (type?: MetricExpressionType) => void;
  37. duplicateWidget: (index: number) => void;
  38. focusArea: FocusAreaProps;
  39. hasMetrics: boolean;
  40. isDefaultQuery: boolean;
  41. isMultiChartMode: boolean;
  42. removeWidget: (index: number) => void;
  43. selectedWidgetIndex: number;
  44. setDefaultQuery: (query: Record<string, any> | null) => void;
  45. setHighlightedSampleId: (sample?: string) => void;
  46. setIsMultiChartMode: (value: boolean) => void;
  47. setMetricsSamples: React.Dispatch<
  48. React.SetStateAction<MetricsSamplesResults<Field>['data'] | undefined>
  49. >;
  50. setSelectedWidgetIndex: (index: number) => void;
  51. showQuerySymbols: boolean;
  52. toggleWidgetVisibility: (index: number) => void;
  53. updateWidget: (index: number, data: Partial<Omit<MetricsWidget, 'type'>>) => void;
  54. widgets: MetricsWidget[];
  55. highlightedSampleId?: string;
  56. metricsSamples?: MetricsSamplesResults<Field>['data'];
  57. }
  58. export const MetricsContext = createContext<MetricsContextValue>({
  59. addWidget: () => {},
  60. duplicateWidget: () => {},
  61. focusArea: {},
  62. hasMetrics: false,
  63. highlightedSampleId: undefined,
  64. isDefaultQuery: false,
  65. isMultiChartMode: false,
  66. metricsSamples: [],
  67. removeWidget: () => {},
  68. selectedWidgetIndex: 0,
  69. setDefaultQuery: () => {},
  70. setHighlightedSampleId: () => {},
  71. setIsMultiChartMode: () => {},
  72. setMetricsSamples: () => {},
  73. setSelectedWidgetIndex: () => {},
  74. showQuerySymbols: false,
  75. updateWidget: () => {},
  76. widgets: [],
  77. toggleWidgetVisibility: () => {},
  78. });
  79. export function useMetricsContext() {
  80. return useContext(MetricsContext);
  81. }
  82. export function useMetricWidgets() {
  83. const {widgets: urlWidgets} = useLocationQuery({fields: {widgets: decodeScalar}});
  84. const updateQuery = useUpdateQuery();
  85. const widgets = useStructuralSharing(
  86. useMemo<MetricsWidget[]>(() => parseMetricWidgetsQueryParam(urlWidgets), [urlWidgets])
  87. );
  88. // We want to have it as a ref, so that we can use it in the setWidget callback
  89. // without needing to generate a new callback every time the location changes
  90. const currentWidgetsRef = useInstantRef(widgets);
  91. const setWidgets = useCallback(
  92. (newWidgets: React.SetStateAction<MetricsWidget[]>) => {
  93. const currentWidgets = currentWidgetsRef.current;
  94. updateQuery({
  95. widgets: JSON.stringify(
  96. typeof newWidgets === 'function' ? newWidgets(currentWidgets) : newWidgets
  97. ),
  98. });
  99. },
  100. [updateQuery, currentWidgetsRef]
  101. );
  102. const updateWidget = useCallback(
  103. (index: number, data: Partial<Omit<MetricsWidget, 'type'>>) => {
  104. setWidgets(currentWidgets => {
  105. const newWidgets = [...currentWidgets];
  106. newWidgets[index] = {
  107. ...currentWidgets[index],
  108. ...data,
  109. };
  110. return newWidgets;
  111. });
  112. },
  113. [setWidgets]
  114. );
  115. const duplicateWidget = useCallback(
  116. (index: number) => {
  117. setWidgets(currentWidgets => {
  118. const newWidgets = [...currentWidgets];
  119. const newWidget = {...currentWidgets[index]};
  120. newWidget.id = NO_QUERY_ID;
  121. newWidgets.splice(index + 1, 0, newWidget);
  122. return newWidgets;
  123. });
  124. },
  125. [setWidgets]
  126. );
  127. const addWidget = useCallback(
  128. (type: MetricExpressionType = MetricExpressionType.QUERY) => {
  129. const lastIndexOfSameType = currentWidgetsRef.current.findLastIndex(
  130. w => w.type === type
  131. );
  132. if (lastIndexOfSameType > -1) {
  133. duplicateWidget(lastIndexOfSameType);
  134. } else {
  135. setWidgets(currentWidgets => [
  136. ...currentWidgets,
  137. type === MetricExpressionType.QUERY
  138. ? emptyMetricsQueryWidget
  139. : emptyMetricsFormulaWidget,
  140. ]);
  141. }
  142. },
  143. [currentWidgetsRef, duplicateWidget, setWidgets]
  144. );
  145. const removeWidget = useCallback(
  146. (index: number) => {
  147. setWidgets(currentWidgets => {
  148. let newWidgets = [...currentWidgets];
  149. newWidgets.splice(index, 1);
  150. // Ensure that a visible widget remains
  151. if (!newWidgets.find(w => !w.isHidden)) {
  152. newWidgets = newWidgets.map(w => ({...w, isHidden: false}));
  153. }
  154. return newWidgets;
  155. });
  156. },
  157. [setWidgets]
  158. );
  159. return {
  160. widgets,
  161. updateWidget,
  162. addWidget,
  163. removeWidget,
  164. duplicateWidget,
  165. setWidgets,
  166. };
  167. }
  168. const useDefaultQuery = () => {
  169. const router = useRouter();
  170. const [defaultQuery, setDefaultQuery] = useLocalStorageState<Record<
  171. string,
  172. any
  173. > | null>('ddm:default-query', null);
  174. useEffect(() => {
  175. if (defaultQuery && router.location.query.widgets === undefined) {
  176. router.replace({...router.location, query: defaultQuery});
  177. }
  178. // Only call on page load
  179. // eslint-disable-next-line react-hooks/exhaustive-deps
  180. }, []);
  181. return useMemo(
  182. () => ({
  183. defaultQuery,
  184. setDefaultQuery,
  185. isDefaultQuery: !!defaultQuery && isEqual(defaultQuery, router.location.query),
  186. }),
  187. [defaultQuery, router.location.query, setDefaultQuery]
  188. );
  189. };
  190. function useSelectedProjects() {
  191. const {selection} = usePageFilters();
  192. const {projects} = useProjects();
  193. return useMemo(() => {
  194. if (selection.projects.length === 0) {
  195. return projects.filter(project => project.isMember);
  196. }
  197. if (selection.projects.includes(-1)) {
  198. return projects;
  199. }
  200. return projects.filter(project => selection.projects.includes(Number(project.id)));
  201. }, [selection.projects, projects]);
  202. }
  203. export function DDMContextProvider({children}: {children: React.ReactNode}) {
  204. const router = useRouter();
  205. const updateQuery = useUpdateQuery();
  206. const {multiChartMode} = useLocationQuery({fields: {multiChartMode: decodeInteger}});
  207. const isMultiChartMode = multiChartMode === 1;
  208. const {setDefaultQuery, isDefaultQuery} = useDefaultQuery();
  209. const [selectedWidgetIndex, setSelectedWidgetIndex] = useState(0);
  210. const {widgets, updateWidget, addWidget, removeWidget, duplicateWidget} =
  211. useMetricWidgets();
  212. const [metricsSamples, setMetricsSamples] = useState<
  213. MetricsSamplesResults<Field>['data'] | undefined
  214. >();
  215. const [highlightedSampleId, setHighlightedSampleId] = useState<string | undefined>();
  216. const selectedProjects = useSelectedProjects();
  217. const hasMetrics = useMemo(
  218. () =>
  219. selectedProjects.some(
  220. project =>
  221. project.hasCustomMetrics || project.hasSessions || project.firstTransactionEvent
  222. ),
  223. [selectedProjects]
  224. );
  225. const handleSetSelectedWidgetIndex = useCallback(
  226. (value: number) => {
  227. if (!isMultiChartMode) {
  228. return;
  229. }
  230. setSelectedWidgetIndex(value);
  231. },
  232. [isMultiChartMode]
  233. );
  234. const focusAreaSelection = useMemo<FocusAreaSelection | undefined>(
  235. () => router.location.query.focusArea && JSON.parse(router.location.query.focusArea),
  236. [router.location.query.focusArea]
  237. );
  238. const handleAddFocusArea = useCallback(
  239. (area: FocusAreaSelection) => {
  240. if (!area.range.start || !area.range.end) {
  241. Sentry.metrics.increment('ddm.enhance.range-undefined');
  242. return;
  243. }
  244. Sentry.metrics.increment('ddm.enhance.add');
  245. handleSetSelectedWidgetIndex(area.widgetIndex);
  246. updateQuery({focusArea: JSON.stringify(area)}, {replace: true});
  247. },
  248. [handleSetSelectedWidgetIndex, updateQuery]
  249. );
  250. const handleRemoveFocusArea = useCallback(() => {
  251. Sentry.metrics.increment('ddm.enhance.remove');
  252. updateQuery({focusArea: undefined}, {replace: true});
  253. }, [updateQuery]);
  254. const focusArea = useMemo<FocusAreaProps>(() => {
  255. return {
  256. selection: focusAreaSelection,
  257. onAdd: handleAddFocusArea,
  258. onRemove: handleRemoveFocusArea,
  259. };
  260. }, [focusAreaSelection, handleAddFocusArea, handleRemoveFocusArea]);
  261. const handleAddWidget = useCallback(
  262. (type?: MetricExpressionType) => {
  263. addWidget(type);
  264. handleSetSelectedWidgetIndex(widgets.length);
  265. },
  266. [addWidget, handleSetSelectedWidgetIndex, widgets.length]
  267. );
  268. const handleUpdateWidget = useCallback(
  269. (index: number, data: Partial<MetricsWidget>) => {
  270. updateWidget(index, data);
  271. handleSetSelectedWidgetIndex(index);
  272. if (index === focusAreaSelection?.widgetIndex) {
  273. handleRemoveFocusArea();
  274. }
  275. },
  276. [
  277. updateWidget,
  278. handleSetSelectedWidgetIndex,
  279. focusAreaSelection?.widgetIndex,
  280. handleRemoveFocusArea,
  281. ]
  282. );
  283. const handleDuplicate = useCallback(
  284. (index: number) => {
  285. duplicateWidget(index);
  286. handleSetSelectedWidgetIndex(index + 1);
  287. },
  288. [duplicateWidget, handleSetSelectedWidgetIndex]
  289. );
  290. const handleSetIsMultiChartMode = useCallback(
  291. (value: boolean) => {
  292. updateQuery({multiChartMode: value ? 1 : 0}, {replace: true});
  293. updateWidget(0, {focusedSeries: undefined});
  294. const firstVisibleWidgetIndex = widgets.findIndex(w => !w.isHidden);
  295. setSelectedWidgetIndex(firstVisibleWidgetIndex);
  296. },
  297. [updateQuery, updateWidget, widgets]
  298. );
  299. const toggleWidgetVisibility = useCallback(
  300. (index: number) => {
  301. if (index === selectedWidgetIndex) {
  302. const firstVisibleWidgetIndex = widgets.findIndex(w => !w.isHidden);
  303. setSelectedWidgetIndex(firstVisibleWidgetIndex);
  304. }
  305. updateWidget(index, {isHidden: !widgets[index].isHidden});
  306. },
  307. [selectedWidgetIndex, updateWidget, widgets]
  308. );
  309. const selectedWidget = widgets[selectedWidgetIndex];
  310. const isSelectionValid = selectedWidget && !selectedWidget.isHidden;
  311. const contextValue = useMemo<MetricsContextValue>(
  312. () => ({
  313. addWidget: handleAddWidget,
  314. selectedWidgetIndex: isSelectionValid
  315. ? selectedWidgetIndex
  316. : widgets.findIndex(w => !w.isHidden),
  317. setSelectedWidgetIndex: handleSetSelectedWidgetIndex,
  318. updateWidget: handleUpdateWidget,
  319. removeWidget,
  320. duplicateWidget: handleDuplicate,
  321. widgets,
  322. hasMetrics,
  323. focusArea,
  324. setDefaultQuery,
  325. isDefaultQuery,
  326. showQuerySymbols: widgets.length > 1,
  327. highlightedSampleId,
  328. setHighlightedSampleId,
  329. isMultiChartMode: isMultiChartMode,
  330. setIsMultiChartMode: handleSetIsMultiChartMode,
  331. metricsSamples,
  332. setMetricsSamples,
  333. toggleWidgetVisibility,
  334. }),
  335. [
  336. handleAddWidget,
  337. isSelectionValid,
  338. selectedWidgetIndex,
  339. widgets,
  340. handleSetSelectedWidgetIndex,
  341. handleUpdateWidget,
  342. removeWidget,
  343. handleDuplicate,
  344. hasMetrics,
  345. focusArea,
  346. setDefaultQuery,
  347. isDefaultQuery,
  348. highlightedSampleId,
  349. isMultiChartMode,
  350. handleSetIsMultiChartMode,
  351. metricsSamples,
  352. toggleWidgetVisibility,
  353. ]
  354. );
  355. return (
  356. <MetricsContext.Provider value={contextValue}>{children}</MetricsContext.Provider>
  357. );
  358. }