context.tsx 9.1 KB

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