context.tsx 10 KB

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