context.tsx 9.9 KB

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