scratchpadContext.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import {
  2. createContext,
  3. useCallback,
  4. useContext,
  5. useEffect,
  6. useMemo,
  7. useState,
  8. } from 'react';
  9. import {uuid4} from '@sentry/utils';
  10. import isEmpty from 'lodash/isEmpty';
  11. import isEqual from 'lodash/isEqual';
  12. import {useClearQuery, useInstantRef, useUpdateQuery} from 'sentry/utils/metrics';
  13. import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
  14. import useOrganization from 'sentry/utils/useOrganization';
  15. import usePageFilters from 'sentry/utils/usePageFilters';
  16. import usePrevious from 'sentry/utils/usePrevious';
  17. import useRouter from 'sentry/utils/useRouter';
  18. type Scratchpad = {
  19. id: string;
  20. name: string;
  21. query: Record<string, unknown>;
  22. };
  23. type ScratchpadState = {
  24. default: string | null;
  25. scratchpads: Record<string, Scratchpad>;
  26. };
  27. function makeLocalStorageKey(orgSlug: string) {
  28. return `ddm-scratchpads:${orgSlug}`;
  29. }
  30. const EMPTY_QUERY = {};
  31. const mapProjectQueryParam = (project: any) => {
  32. if (typeof project === 'string') {
  33. return [Number(project)];
  34. }
  35. if (Array.isArray(project)) {
  36. return project.map(Number);
  37. }
  38. return [];
  39. };
  40. function useScratchpadUrlSync() {
  41. const {slug} = useOrganization();
  42. const router = useRouter();
  43. const updateQuery = useUpdateQuery();
  44. const clearQuery = useClearQuery();
  45. const {projects} = usePageFilters().selection;
  46. const [state, setState] = useLocalStorageState<ScratchpadState>(
  47. makeLocalStorageKey(slug),
  48. {
  49. default: null,
  50. scratchpads: {},
  51. }
  52. );
  53. const stateRef = useInstantRef(state);
  54. const routerQuery = router.location.query ?? EMPTY_QUERY;
  55. const routerQueryRef = useInstantRef(routerQuery);
  56. const [selected, setSelected] = useState<string | null | undefined>(() => {
  57. if (
  58. (state.default && !routerQuery.widgets) ||
  59. (state.default && isEqual(state.scratchpads[state.default].query, routerQuery))
  60. ) {
  61. return state.default;
  62. }
  63. return undefined;
  64. });
  65. const savedProjects = selected && state.scratchpads[selected].query.project;
  66. // The scratchpad is "loading" while the project selection state is different from the saved state
  67. const isLoading = !!selected && !isEqual(mapProjectQueryParam(savedProjects), projects);
  68. const toggleSelected = useCallback(
  69. (id: string | null) => {
  70. if (id === selected) {
  71. setSelected(null);
  72. } else {
  73. setSelected(id);
  74. }
  75. },
  76. [setSelected, selected]
  77. );
  78. const setDefault = useCallback(
  79. (id: string | null) => {
  80. setState({...state, default: id});
  81. },
  82. [state, setState]
  83. );
  84. const add = useCallback(
  85. (name: string) => {
  86. const currentState = stateRef.current;
  87. const id = uuid4();
  88. const newScratchpads = {
  89. ...currentState.scratchpads,
  90. [id]: {
  91. name,
  92. id,
  93. query: {environment: null, statsPeriod: null, ...routerQueryRef.current},
  94. },
  95. };
  96. setState({...currentState, scratchpads: newScratchpads});
  97. toggleSelected(id);
  98. },
  99. [stateRef, routerQueryRef, setState, toggleSelected]
  100. );
  101. const update = useCallback(
  102. (id: string, query: Scratchpad['query']) => {
  103. const currentState = stateRef.current;
  104. const oldScratchpad = currentState.scratchpads[id];
  105. const newQuery = {
  106. ...query,
  107. };
  108. const newScratchpads = {
  109. ...currentState.scratchpads,
  110. [id]: {...oldScratchpad, query: newQuery},
  111. };
  112. setState({...currentState, scratchpads: newScratchpads});
  113. },
  114. [setState, stateRef]
  115. );
  116. const remove = useCallback(
  117. (id: string) => {
  118. const currentState = stateRef.current;
  119. const newScratchpads = {...currentState.scratchpads};
  120. delete newScratchpads[id];
  121. if (currentState.default === id) {
  122. setState({...currentState, default: null, scratchpads: newScratchpads});
  123. } else {
  124. setState({...currentState, scratchpads: newScratchpads});
  125. }
  126. if (selected === id) {
  127. toggleSelected(null);
  128. }
  129. },
  130. [stateRef, selected, setState, toggleSelected]
  131. );
  132. // Changes the query when a scratchpad is selected, clears it when none is selected
  133. useEffect(() => {
  134. const selectedQuery = selected && stateRef.current.scratchpads[selected].query;
  135. if (selectedQuery && !isEqual(selectedQuery, routerQueryRef.current)) {
  136. const queryCopy: Record<string, any> = {
  137. project: undefined, // make sure that project will be removed if not present in the stored query
  138. ...selectedQuery,
  139. };
  140. // If the selected scratchpad has a start and end date, remove the statsPeriod
  141. if (selectedQuery.start && selectedQuery.end) {
  142. delete queryCopy.statsPeriod;
  143. }
  144. updateQuery(queryCopy);
  145. } else if (selectedQuery === null) {
  146. clearQuery();
  147. }
  148. }, [clearQuery, updateQuery, selected, routerQueryRef, stateRef]);
  149. const previousSelected = usePrevious(selected);
  150. // Saves all URL changes to the selected scratchpad to local storage
  151. useEffect(() => {
  152. const selectedQuery = selected && stateRef.current.scratchpads[selected].query;
  153. // normal update path
  154. if (selected && !isEmpty(routerQuery) && !isLoading) {
  155. update(selected, routerQuery);
  156. // project selection changes should ignore loading state
  157. } else if (
  158. selectedQuery &&
  159. isLoading &&
  160. selected === previousSelected &&
  161. routerQuery.project !== selectedQuery.project
  162. ) {
  163. update(selected, routerQuery);
  164. }
  165. }, [routerQuery, projects, selected, update, isLoading, stateRef, previousSelected]);
  166. return useMemo(
  167. () => ({
  168. all: state.scratchpads,
  169. default: state.default,
  170. selected,
  171. isLoading,
  172. add,
  173. update,
  174. remove,
  175. toggleSelected,
  176. setDefault,
  177. }),
  178. [state, selected, isLoading, add, update, remove, toggleSelected, setDefault]
  179. );
  180. }
  181. const Context = createContext<ReturnType<typeof useScratchpadUrlSync>>({
  182. all: {},
  183. default: null,
  184. selected: null,
  185. isLoading: false,
  186. add: () => {},
  187. update: () => {},
  188. remove: () => {},
  189. toggleSelected: () => {},
  190. setDefault: () => {},
  191. });
  192. export const useScratchpads = () => {
  193. return useContext(Context);
  194. };
  195. export function ScratchpadsProvider({children}: {children: React.ReactNode}) {
  196. const contextValue = useScratchpadUrlSync();
  197. return <Context.Provider value={contextValue}>{children}</Context.Provider>;
  198. }