metricScratchpad.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {FocusScope} from '@react-aria/focus';
  5. import {uuid4} from '@sentry/utils';
  6. import {AnimatePresence} from 'framer-motion';
  7. import {Button} from 'sentry/components/button';
  8. import {CompactSelect} from 'sentry/components/compactSelect';
  9. import {openConfirmModal} from 'sentry/components/confirm';
  10. import InputControl from 'sentry/components/input';
  11. import {Overlay, PositionWrapper} from 'sentry/components/overlay';
  12. import {IconBookmark, IconDelete, IconStar} from 'sentry/icons';
  13. import {t} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import {clearQuery, updateQuery} from 'sentry/utils/metrics';
  16. import useKeyPress from 'sentry/utils/useKeyPress';
  17. import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
  18. import useOverlay from 'sentry/utils/useOverlay';
  19. import useRouter from 'sentry/utils/useRouter';
  20. type Scratchpad = {
  21. id: string;
  22. name: string;
  23. query: Record<string, unknown>;
  24. };
  25. type ScratchpadState = {
  26. default: string | null;
  27. scratchpads: Record<string, Scratchpad>;
  28. };
  29. export function useScratchpads() {
  30. const [state, setState] = useLocalStorageState<ScratchpadState>('ddm-scratchpads', {
  31. default: null,
  32. scratchpads: {},
  33. });
  34. const [selected, setSelected] = useState<string | null | undefined>(state.default); // scratchpad id
  35. const router = useRouter();
  36. const routerQuery = useMemo(() => router.location.query ?? {}, [router.location.query]);
  37. // changes the query when a scratchpad is selected, clears it when none is selected
  38. useEffect(() => {
  39. if (selected) {
  40. const selectedQuery = state.scratchpads[selected].query;
  41. updateQuery(router, selectedQuery);
  42. } else if (selected === null) {
  43. clearQuery(router);
  44. }
  45. // eslint-disable-next-line react-hooks/exhaustive-deps
  46. }, [selected]);
  47. // saves all changes to the selected scratchpad to local storage
  48. useEffect(() => {
  49. if (selected) {
  50. update(selected, routerQuery);
  51. }
  52. // eslint-disable-next-line react-hooks/exhaustive-deps
  53. }, [routerQuery]);
  54. const toggleSelected = useCallback(
  55. (id: string | null) => {
  56. if (id === selected) {
  57. setSelected(null);
  58. } else {
  59. setSelected(id);
  60. }
  61. },
  62. [setSelected, selected]
  63. );
  64. const setDefault = useCallback(
  65. (id: string | null) => {
  66. setState({...state, default: id});
  67. },
  68. [state, setState]
  69. );
  70. const add = useCallback(
  71. (name: string) => {
  72. const id = uuid4();
  73. const newScratchpads = {...state.scratchpads, [id]: {name, id, query: routerQuery}};
  74. setState({...state, scratchpads: newScratchpads});
  75. toggleSelected(id);
  76. },
  77. [state, setState, toggleSelected, routerQuery]
  78. );
  79. const update = useCallback(
  80. (id: string, query: Scratchpad['query']) => {
  81. const oldScratchpad = state.scratchpads[id];
  82. const newScratchpads = {...state.scratchpads, [id]: {...oldScratchpad, query}};
  83. setState({...state, scratchpads: newScratchpads});
  84. },
  85. [state, setState]
  86. );
  87. const remove = useCallback(
  88. (id: string) => {
  89. const newScratchpads = {...state.scratchpads};
  90. delete newScratchpads[id];
  91. if (state.default === id) {
  92. setState({...state, default: null, scratchpads: newScratchpads});
  93. } else {
  94. setState({...state, scratchpads: newScratchpads});
  95. }
  96. if (selected === id) {
  97. toggleSelected(null);
  98. }
  99. },
  100. [state, setState, toggleSelected, selected]
  101. );
  102. return {
  103. all: state.scratchpads,
  104. default: state.default,
  105. selected,
  106. add,
  107. update,
  108. remove,
  109. toggleSelected,
  110. setDefault,
  111. };
  112. }
  113. export function ScratchpadSelector() {
  114. const scratchpads = useScratchpads();
  115. const scratchpadOptions = useMemo(
  116. () =>
  117. Object.values(scratchpads.all).map((s: any) => ({
  118. value: s.id,
  119. label: s.name,
  120. trailingItems: (
  121. <Fragment>
  122. <StyledDropdownIcon>
  123. <IconDelete
  124. onClick={() => {
  125. openConfirmModal({
  126. onConfirm: () => scratchpads.remove(s.id),
  127. message: t('Are you sure you want to delete this scratchpad?'),
  128. confirmText: t('Delete'),
  129. });
  130. }}
  131. />
  132. </StyledDropdownIcon>
  133. </Fragment>
  134. ),
  135. })),
  136. [scratchpads]
  137. );
  138. const isDefaultSelected = scratchpads.default === scratchpads.selected;
  139. return (
  140. <ScratchpadGroup>
  141. <Button
  142. disabled={!scratchpads.selected}
  143. onClick={() => {
  144. if (isDefaultSelected) {
  145. scratchpads.setDefault(null);
  146. } else {
  147. scratchpads.setDefault(scratchpads.selected ?? null);
  148. }
  149. }}
  150. icon={<IconBookmark isSolid={isDefaultSelected} />}
  151. >
  152. {isDefaultSelected ? t('Remove default') : t('Set as default')}
  153. </Button>
  154. <SaveAsDropdown
  155. onSave={name => {
  156. scratchpads.add(name);
  157. }}
  158. mode={scratchpads.selected ? 'fork' : 'save'}
  159. />
  160. <CompactSelect
  161. options={scratchpadOptions}
  162. value={scratchpads.selected ?? `None`}
  163. closeOnSelect={false}
  164. onChange={option => {
  165. scratchpads.toggleSelected(option.value);
  166. }}
  167. triggerProps={{prefix: t('Scratchpad')}}
  168. emptyMessage="No scratchpads yet."
  169. disabled={false}
  170. />
  171. </ScratchpadGroup>
  172. );
  173. }
  174. function SaveAsDropdown({
  175. onSave,
  176. mode,
  177. }: {
  178. mode: 'save' | 'fork';
  179. onSave: (name: string) => void;
  180. }) {
  181. const {
  182. isOpen,
  183. triggerProps,
  184. overlayProps,
  185. arrowProps,
  186. state: {setOpen},
  187. } = useOverlay({});
  188. const theme = useTheme();
  189. const [name, setName] = useState('');
  190. const save = useCallback(() => {
  191. onSave(name);
  192. setOpen(false);
  193. setName('');
  194. }, [name, onSave, setOpen]);
  195. const enterKeyPressed = useKeyPress('Enter', undefined, true);
  196. useEffect(() => {
  197. if (isOpen && enterKeyPressed && name) {
  198. save();
  199. }
  200. }, [enterKeyPressed, isOpen, name, save]);
  201. return (
  202. <div>
  203. <Button icon={<IconStar />} {...triggerProps}>
  204. {mode === 'fork' ? t('Fork as ...') : t('Save as ...')}
  205. </Button>
  206. <AnimatePresence>
  207. {isOpen && (
  208. <FocusScope contain restoreFocus autoFocus>
  209. <PositionWrapper zIndex={theme.zIndex.dropdown} {...overlayProps}>
  210. <StyledOverlay arrowProps={arrowProps} animated>
  211. <SaveAsInput
  212. type="txt"
  213. name="scratchpad-name"
  214. placeholder={t('Scratchpad name')}
  215. value={name}
  216. size="sm"
  217. onChange={({target}) => setName(target.value)}
  218. />
  219. <SaveAsButton
  220. priority="primary"
  221. disabled={!name}
  222. onClick={() => {
  223. save();
  224. }}
  225. >
  226. {mode === 'fork' ? t('Fork') : t('Save')}
  227. </SaveAsButton>
  228. </StyledOverlay>
  229. </PositionWrapper>
  230. </FocusScope>
  231. )}
  232. </AnimatePresence>
  233. </div>
  234. );
  235. }
  236. const ScratchpadGroup = styled('div')`
  237. display: flex;
  238. gap: ${space(1)};
  239. `;
  240. const StyledOverlay = styled(Overlay)`
  241. padding: ${space(1)};
  242. `;
  243. const SaveAsButton = styled(Button)`
  244. width: 100%;
  245. `;
  246. const SaveAsInput = styled(InputControl)`
  247. margin-bottom: ${space(1)};
  248. `;
  249. const StyledDropdownIcon = styled('span')`
  250. padding-top: ${space(0.5)};
  251. opacity: 0.6;
  252. :hover {
  253. opacity: 0.9;
  254. color: ${p => p.theme.red300};
  255. }
  256. `;