context.tsx 11 KB

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