context.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  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 addWidget = useCallback(
  122. (type: MetricQueryType = MetricQueryType.QUERY) => {
  123. setWidgets(currentWidgets => {
  124. const lastWidget =
  125. currentWidgets.length > 0
  126. ? currentWidgets[currentWidgets.length - 1]
  127. : undefined;
  128. let newWidget =
  129. type === MetricQueryType.QUERY
  130. ? emptyMetricsQueryWidget
  131. : emptyMetricsFormulaWidget;
  132. // if the last widget is of the same type, we duplicate it
  133. if (lastWidget && lastWidget.type === newWidget.type) {
  134. newWidget = {
  135. ...newWidget,
  136. ...lastWidget,
  137. };
  138. }
  139. newWidget.id = NO_QUERY_ID;
  140. return [...currentWidgets, newWidget];
  141. });
  142. },
  143. [setWidgets]
  144. );
  145. const removeWidget = useCallback(
  146. (index: number) => {
  147. setWidgets(currentWidgets => {
  148. let newWidgets = [...currentWidgets];
  149. newWidgets.splice(index, 1);
  150. // Ensure that a visible widget remains
  151. if (!newWidgets.find(w => !w.isHidden)) {
  152. newWidgets = newWidgets.map(w => ({...w, isHidden: false}));
  153. }
  154. return newWidgets;
  155. });
  156. },
  157. [setWidgets]
  158. );
  159. const duplicateWidget = useCallback(
  160. (index: number) => {
  161. setWidgets(currentWidgets => {
  162. const newWidgets = [...currentWidgets];
  163. const newWidget = {...currentWidgets[index]};
  164. newWidget.id = NO_QUERY_ID;
  165. newWidgets.splice(index + 1, 0, newWidget);
  166. return newWidgets;
  167. });
  168. },
  169. [setWidgets]
  170. );
  171. return {
  172. widgets,
  173. updateWidget,
  174. addWidget,
  175. removeWidget,
  176. duplicateWidget,
  177. setWidgets,
  178. };
  179. }
  180. const useDefaultQuery = () => {
  181. const router = useRouter();
  182. const [defaultQuery, setDefaultQuery] = useLocalStorageState<Record<
  183. string,
  184. any
  185. > | null>('ddm:default-query', null);
  186. useEffect(() => {
  187. if (defaultQuery && router.location.query.widgets === undefined) {
  188. router.replace({...router.location, query: defaultQuery});
  189. }
  190. // Only call on page load
  191. // eslint-disable-next-line react-hooks/exhaustive-deps
  192. }, []);
  193. return useMemo(
  194. () => ({
  195. defaultQuery,
  196. setDefaultQuery,
  197. isDefaultQuery: !!defaultQuery && isEqual(defaultQuery, router.location.query),
  198. }),
  199. [defaultQuery, router.location.query, setDefaultQuery]
  200. );
  201. };
  202. function useSelectedProjects() {
  203. const {selection} = usePageFilters();
  204. const {projects} = useProjects();
  205. return useMemo(() => {
  206. if (selection.projects.length === 0) {
  207. return projects.filter(project => project.isMember);
  208. }
  209. if (selection.projects.includes(-1)) {
  210. return projects;
  211. }
  212. return projects.filter(project => selection.projects.includes(Number(project.id)));
  213. }, [selection.projects, projects]);
  214. }
  215. export function DDMContextProvider({children}: {children: React.ReactNode}) {
  216. const router = useRouter();
  217. const updateQuery = useUpdateQuery();
  218. const {multiChartMode} = useLocationQuery({fields: {multiChartMode: decodeInteger}});
  219. const isMultiChartMode = multiChartMode === 1;
  220. const {setDefaultQuery, isDefaultQuery} = useDefaultQuery();
  221. const [selectedWidgetIndex, setSelectedWidgetIndex] = useState(0);
  222. const {widgets, updateWidget, addWidget, removeWidget, duplicateWidget} =
  223. useMetricWidgets();
  224. const [metricsSamples, setMetricsSamples] = useState<
  225. MetricsSamplesResults<Field>['data'] | undefined
  226. >();
  227. const [highlightedSampleId, setHighlightedSampleId] = useState<string | undefined>();
  228. const selectedProjects = useSelectedProjects();
  229. const hasMetrics = useMemo(
  230. () =>
  231. selectedProjects.some(
  232. project =>
  233. project.hasCustomMetrics || project.hasSessions || project.firstTransactionEvent
  234. ),
  235. [selectedProjects]
  236. );
  237. const handleSetSelectedWidgetIndex = useCallback(
  238. (value: number) => {
  239. if (!isMultiChartMode) {
  240. return;
  241. }
  242. setSelectedWidgetIndex(value);
  243. },
  244. [isMultiChartMode]
  245. );
  246. const focusAreaSelection = useMemo<FocusAreaSelection | undefined>(
  247. () => router.location.query.focusArea && JSON.parse(router.location.query.focusArea),
  248. [router.location.query.focusArea]
  249. );
  250. const handleAddFocusArea = useCallback(
  251. (area: FocusAreaSelection) => {
  252. if (!area.range.start || !area.range.end) {
  253. Sentry.metrics.increment('ddm.enhance.range-undefined');
  254. return;
  255. }
  256. Sentry.metrics.increment('ddm.enhance.add');
  257. handleSetSelectedWidgetIndex(area.widgetIndex);
  258. updateQuery({focusArea: JSON.stringify(area)}, {replace: true});
  259. },
  260. [handleSetSelectedWidgetIndex, updateQuery]
  261. );
  262. const handleRemoveFocusArea = useCallback(() => {
  263. Sentry.metrics.increment('ddm.enhance.remove');
  264. updateQuery({focusArea: undefined}, {replace: true});
  265. }, [updateQuery]);
  266. const focusArea = useMemo<FocusAreaProps>(() => {
  267. return {
  268. selection: focusAreaSelection,
  269. onAdd: handleAddFocusArea,
  270. onRemove: handleRemoveFocusArea,
  271. };
  272. }, [focusAreaSelection, handleAddFocusArea, handleRemoveFocusArea]);
  273. const handleAddWidget = useCallback(
  274. (type?: MetricQueryType) => {
  275. addWidget(type);
  276. handleSetSelectedWidgetIndex(widgets.length);
  277. },
  278. [addWidget, handleSetSelectedWidgetIndex, widgets.length]
  279. );
  280. const handleUpdateWidget = useCallback(
  281. (index: number, data: Partial<MetricWidgetQueryParams>) => {
  282. updateWidget(index, data);
  283. handleSetSelectedWidgetIndex(index);
  284. if (index === focusAreaSelection?.widgetIndex) {
  285. handleRemoveFocusArea();
  286. }
  287. },
  288. [
  289. updateWidget,
  290. handleSetSelectedWidgetIndex,
  291. focusAreaSelection?.widgetIndex,
  292. handleRemoveFocusArea,
  293. ]
  294. );
  295. const handleDuplicate = useCallback(
  296. (index: number) => {
  297. duplicateWidget(index);
  298. handleSetSelectedWidgetIndex(index + 1);
  299. },
  300. [duplicateWidget, handleSetSelectedWidgetIndex]
  301. );
  302. const handleSetIsMultiChartMode = useCallback(
  303. (value: boolean) => {
  304. updateQuery({multiChartMode: value ? 1 : 0}, {replace: true});
  305. updateWidget(0, {focusedSeries: undefined});
  306. const firstVisibleWidgetIndex = widgets.findIndex(w => !w.isHidden);
  307. setSelectedWidgetIndex(firstVisibleWidgetIndex);
  308. },
  309. [updateQuery, updateWidget, widgets]
  310. );
  311. const toggleWidgetVisibility = useCallback(
  312. (index: number) => {
  313. if (index === selectedWidgetIndex) {
  314. const firstVisibleWidgetIndex = widgets.findIndex(w => !w.isHidden);
  315. setSelectedWidgetIndex(firstVisibleWidgetIndex);
  316. }
  317. updateWidget(index, {isHidden: !widgets[index].isHidden});
  318. },
  319. [selectedWidgetIndex, updateWidget, widgets]
  320. );
  321. const selectedWidget = widgets[selectedWidgetIndex];
  322. const isSelectionValid = selectedWidget && !selectedWidget.isHidden;
  323. const contextValue = useMemo<DDMContextValue>(
  324. () => ({
  325. addWidget: handleAddWidget,
  326. selectedWidgetIndex: isSelectionValid
  327. ? selectedWidgetIndex
  328. : widgets.findIndex(w => !w.isHidden),
  329. setSelectedWidgetIndex: handleSetSelectedWidgetIndex,
  330. updateWidget: handleUpdateWidget,
  331. removeWidget,
  332. duplicateWidget: handleDuplicate,
  333. widgets,
  334. hasMetrics,
  335. focusArea,
  336. setDefaultQuery,
  337. isDefaultQuery,
  338. showQuerySymbols: widgets.length > 1,
  339. highlightedSampleId,
  340. setHighlightedSampleId,
  341. isMultiChartMode: isMultiChartMode,
  342. setIsMultiChartMode: handleSetIsMultiChartMode,
  343. metricsSamples,
  344. setMetricsSamples,
  345. toggleWidgetVisibility,
  346. }),
  347. [
  348. handleAddWidget,
  349. isSelectionValid,
  350. selectedWidgetIndex,
  351. widgets,
  352. handleSetSelectedWidgetIndex,
  353. handleUpdateWidget,
  354. removeWidget,
  355. handleDuplicate,
  356. hasMetrics,
  357. focusArea,
  358. setDefaultQuery,
  359. isDefaultQuery,
  360. highlightedSampleId,
  361. isMultiChartMode,
  362. handleSetIsMultiChartMode,
  363. metricsSamples,
  364. toggleWidgetVisibility,
  365. ]
  366. );
  367. return <DDMContext.Provider value={contextValue}>{children}</DDMContext.Provider>;
  368. }