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 {MRI} from 'sentry/types';
  12. import {
  13. getAbsoluteDateTimeRange,
  14. getDefaultMetricDisplayType,
  15. MetricDisplayType,
  16. MetricWidgetQueryParams,
  17. useInstantRef,
  18. useUpdateQuery,
  19. } from 'sentry/utils/metrics';
  20. import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
  21. import {decodeList} from 'sentry/utils/queryString';
  22. import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
  23. import usePageFilters from 'sentry/utils/usePageFilters';
  24. import useRouter from 'sentry/utils/useRouter';
  25. import {FocusArea} from 'sentry/views/ddm/chartBrush';
  26. import {DEFAULT_SORT_STATE} from 'sentry/views/ddm/constants';
  27. import {useStructuralSharing} from 'sentry/views/ddm/useStructuralSharing';
  28. interface DDMContextValue {
  29. addFocusArea: (area: FocusArea) => void;
  30. addWidget: () => void;
  31. addWidgets: (widgets: Partial<MetricWidgetQueryParams>[]) => void;
  32. duplicateWidget: (index: number) => void;
  33. focusArea: FocusArea | null;
  34. isDefaultQuery: boolean;
  35. isLoading: boolean;
  36. metricsMeta: ReturnType<typeof useMetricsMeta>['data'];
  37. removeFocusArea: () => void;
  38. removeWidget: (index: number) => void;
  39. selectedWidgetIndex: number;
  40. setDefaultQuery: (query: Record<string, any> | null) => void;
  41. setSelectedWidgetIndex: (index: number) => void;
  42. updateWidget: (index: number, data: Partial<MetricWidgetQueryParams>) => void;
  43. widgets: MetricWidgetQueryParams[];
  44. }
  45. export const DDMContext = createContext<DDMContextValue>({
  46. addFocusArea: () => {},
  47. addWidget: () => {},
  48. addWidgets: () => {},
  49. duplicateWidget: () => {},
  50. focusArea: null,
  51. isDefaultQuery: false,
  52. isLoading: false,
  53. metricsMeta: [],
  54. removeFocusArea: () => {},
  55. removeWidget: () => {},
  56. selectedWidgetIndex: 0,
  57. setDefaultQuery: () => {},
  58. setSelectedWidgetIndex: () => {},
  59. updateWidget: () => {},
  60. widgets: [],
  61. });
  62. export function useDDMContext() {
  63. return useContext(DDMContext);
  64. }
  65. const emptyWidget: MetricWidgetQueryParams = {
  66. mri: 'd:transactions/duration@millisecond' satisfies MRI,
  67. op: 'avg',
  68. query: '',
  69. groupBy: [],
  70. sort: DEFAULT_SORT_STATE,
  71. displayType: MetricDisplayType.LINE,
  72. title: undefined,
  73. };
  74. export function useMetricWidgets() {
  75. const router = useRouter();
  76. const updateQuery = useUpdateQuery();
  77. const widgets = useStructuralSharing(
  78. useMemo<MetricWidgetQueryParams[]>(() => {
  79. const currentWidgets = JSON.parse(
  80. router.location.query.widgets ?? JSON.stringify([emptyWidget])
  81. );
  82. return currentWidgets.map((widget: MetricWidgetQueryParams) => {
  83. return {
  84. mri: widget.mri,
  85. op: widget.op,
  86. query: widget.query,
  87. groupBy: decodeList(widget.groupBy),
  88. displayType:
  89. widget.displayType ?? getDefaultMetricDisplayType(widget.mri, widget.op),
  90. focusedSeries: widget.focusedSeries,
  91. showSummaryTable: widget.showSummaryTable ?? true, // temporary default
  92. powerUserMode: widget.powerUserMode,
  93. sort: widget.sort ?? DEFAULT_SORT_STATE,
  94. title: widget.title,
  95. };
  96. });
  97. }, [router.location.query.widgets])
  98. );
  99. // We want to have it as a ref, so that we can use it in the setWidget callback
  100. // without needing to generate a new callback every time the location changes
  101. const currentWidgetsRef = useInstantRef(widgets);
  102. const setWidgets = useCallback(
  103. (newWidgets: React.SetStateAction<MetricWidgetQueryParams[]>) => {
  104. const currentWidgets = currentWidgetsRef.current;
  105. updateQuery({
  106. widgets: JSON.stringify(
  107. typeof newWidgets === 'function' ? newWidgets(currentWidgets) : newWidgets
  108. ),
  109. });
  110. },
  111. [updateQuery, currentWidgetsRef]
  112. );
  113. const updateWidget = useCallback(
  114. (index: number, data: Partial<MetricWidgetQueryParams>) => {
  115. setWidgets(currentWidgets => {
  116. const newWidgets = [...currentWidgets];
  117. newWidgets[index] = {...currentWidgets[index], ...data};
  118. return newWidgets;
  119. });
  120. },
  121. [setWidgets]
  122. );
  123. const addWidget = useCallback(() => {
  124. setWidgets(currentWidgets => [...currentWidgets, emptyWidget]);
  125. }, [setWidgets]);
  126. const addWidgets = useCallback(
  127. (newWidgets: Partial<MetricWidgetQueryParams>[]) => {
  128. const widgetsCopy = [...widgets].filter(widget => !!widget.mri);
  129. widgetsCopy.push(...newWidgets.map(widget => ({...emptyWidget, ...widget})));
  130. setWidgets(widgetsCopy);
  131. },
  132. [widgets, setWidgets]
  133. );
  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. addWidgets,
  159. removeWidget,
  160. duplicateWidget,
  161. };
  162. }
  163. const useDefaultQuery = () => {
  164. const router = useRouter();
  165. const [defaultQuery, setDefaultQuery] = useLocalStorageState<Record<
  166. string,
  167. any
  168. > | null>('ddm:default-query', null);
  169. useEffect(() => {
  170. if (defaultQuery) {
  171. router.replace({...router.location, query: defaultQuery});
  172. }
  173. // Only call on page load
  174. // eslint-disable-next-line react-hooks/exhaustive-deps
  175. }, []);
  176. return useMemo(
  177. () => ({
  178. defaultQuery,
  179. setDefaultQuery,
  180. isDefaultQuery: !!defaultQuery && isEqual(defaultQuery, router.location.query),
  181. }),
  182. [defaultQuery, router.location.query, setDefaultQuery]
  183. );
  184. };
  185. export function DDMContextProvider({children}: {children: React.ReactNode}) {
  186. const router = useRouter();
  187. const updateQuery = useUpdateQuery();
  188. const {setDefaultQuery, isDefaultQuery} = useDefaultQuery();
  189. const [selectedWidgetIndex, setSelectedWidgetIndex] = useState(0);
  190. const {widgets, updateWidget, addWidget, addWidgets, removeWidget, duplicateWidget} =
  191. useMetricWidgets();
  192. const [focusArea, setFocusArea] = useState<FocusArea | null>(null);
  193. const pageFilters = usePageFilters().selection;
  194. const {data: metricsMeta, isLoading} = useMetricsMeta(pageFilters.projects);
  195. const handleAddFocusArea = useCallback(
  196. (area: FocusArea) => {
  197. const dateRange = getAbsoluteDateTimeRange(pageFilters.datetime);
  198. if (!area.range.start || !area.range.end) {
  199. Sentry.metrics.increment('ddm.enhance.range-undefined');
  200. return;
  201. }
  202. if (area.range.start < dateRange.start || area.range.end > dateRange.end) {
  203. Sentry.metrics.increment('ddm.enhance.range-overflow');
  204. return;
  205. }
  206. Sentry.metrics.increment('ddm.enhance.add');
  207. setFocusArea(area);
  208. setSelectedWidgetIndex(area.widgetIndex);
  209. updateQuery({focusArea: JSON.stringify(area)});
  210. },
  211. [updateQuery, pageFilters.datetime]
  212. );
  213. const handleRemoveFocusArea = useCallback(() => {
  214. Sentry.metrics.increment('ddm.enhance.remove');
  215. setFocusArea(null);
  216. updateQuery({focusArea: null});
  217. }, [updateQuery]);
  218. // Load focus area from URL
  219. useEffect(() => {
  220. if (focusArea) {
  221. return;
  222. }
  223. const urlFocusArea = router.location.query.focusArea;
  224. if (urlFocusArea) {
  225. handleAddFocusArea(JSON.parse(urlFocusArea));
  226. }
  227. }, [router, handleAddFocusArea, focusArea]);
  228. const handleAddWidget = useCallback(() => {
  229. addWidget();
  230. setSelectedWidgetIndex(widgets.length);
  231. }, [addWidget, widgets.length]);
  232. const handleUpdateWidget = useCallback(
  233. (index: number, data: Partial<MetricWidgetQueryParams>) => {
  234. updateWidget(index, data);
  235. setSelectedWidgetIndex(index);
  236. if (index === focusArea?.widgetIndex) {
  237. handleRemoveFocusArea();
  238. }
  239. },
  240. [updateWidget, handleRemoveFocusArea, focusArea?.widgetIndex]
  241. );
  242. const handleDuplicate = useCallback(
  243. (index: number) => {
  244. duplicateWidget(index);
  245. setSelectedWidgetIndex(index + 1);
  246. },
  247. [duplicateWidget]
  248. );
  249. const contextValue = useMemo<DDMContextValue>(
  250. () => ({
  251. addWidget: handleAddWidget,
  252. addWidgets,
  253. selectedWidgetIndex:
  254. selectedWidgetIndex > widgets.length - 1 ? 0 : selectedWidgetIndex,
  255. setSelectedWidgetIndex,
  256. updateWidget: handleUpdateWidget,
  257. removeWidget,
  258. duplicateWidget: handleDuplicate,
  259. widgets,
  260. isLoading,
  261. metricsMeta,
  262. focusArea,
  263. addFocusArea: handleAddFocusArea,
  264. removeFocusArea: handleRemoveFocusArea,
  265. setDefaultQuery,
  266. isDefaultQuery,
  267. }),
  268. [
  269. handleAddWidget,
  270. addWidgets,
  271. selectedWidgetIndex,
  272. widgets,
  273. handleUpdateWidget,
  274. removeWidget,
  275. handleDuplicate,
  276. isLoading,
  277. metricsMeta,
  278. focusArea,
  279. handleAddFocusArea,
  280. handleRemoveFocusArea,
  281. setDefaultQuery,
  282. isDefaultQuery,
  283. ]
  284. );
  285. return <DDMContext.Provider value={contextValue}>{children}</DDMContext.Provider>;
  286. }