scratchpadSelector.tsx 9.9 KB

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