context.tsx 12 KB

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