scratchpadSelector.tsx 9.4 KB

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