context.tsx 10 KB

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