scratchpadSelector.tsx 9.3 KB

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