scratchpadSelector.tsx 11 KB

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