scratchpadSelector.tsx 10 KB

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