context.tsx 11 KB

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