scratchpadSelector.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  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 {AnimatePresence} from 'framer-motion';
  7. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  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 useKeyPress from 'sentry/utils/useKeyPress';
  19. import useOrganization from 'sentry/utils/useOrganization';
  20. import useOverlay from 'sentry/utils/useOverlay';
  21. import {useScratchpads} from 'sentry/views/ddm/scratchpadContext';
  22. import {useCreateDashboard} from './useCreateDashboard';
  23. export function ScratchpadSelector() {
  24. const scratchpads = useScratchpads();
  25. const organization = useOrganization();
  26. const createDashboard = useCreateDashboard();
  27. const isDefault = useCallback(
  28. scratchpad => scratchpads.default === scratchpad.id,
  29. [scratchpads.default]
  30. );
  31. const scratchpadOptions = useMemo(
  32. () =>
  33. Object.values(scratchpads.all).map(scratchpad => ({
  34. value: scratchpad.id,
  35. label: scratchpad.name,
  36. trailingItems: (
  37. <Fragment>
  38. <Tooltip
  39. title={
  40. isDefault(scratchpad)
  41. ? t('Remove as default scratchpad')
  42. : t('Set as default scratchpad')
  43. }
  44. >
  45. <Button
  46. size="zero"
  47. borderless
  48. onPointerDown={e => e.stopPropagation()}
  49. onClick={() => {
  50. trackAnalytics('ddm.scratchpad.set-default', {
  51. organization,
  52. });
  53. Sentry.metrics.increment('ddm.scratchpad.set_default');
  54. if (isDefault(scratchpad)) {
  55. scratchpads.setDefault(null);
  56. } else {
  57. scratchpads.setDefault(scratchpad.id ?? null);
  58. }
  59. }}
  60. >
  61. <StyledDropdownIcon>
  62. <IconBookmark isSolid={isDefault(scratchpad)} />
  63. </StyledDropdownIcon>
  64. </Button>
  65. </Tooltip>
  66. <Tooltip title={t('Remove scratchpad')}>
  67. <Button
  68. size="zero"
  69. borderless
  70. onPointerDown={e => e.stopPropagation()}
  71. onClick={() => {
  72. openConfirmModal({
  73. onConfirm: () => {
  74. trackAnalytics('ddm.scratchpad.remove', {
  75. organization,
  76. });
  77. Sentry.metrics.increment('ddm.scratchpad.remove');
  78. scratchpads.remove(scratchpad.id);
  79. },
  80. message: t('Are you sure you want to delete this scratchpad?'),
  81. confirmText: t('Delete'),
  82. });
  83. }}
  84. >
  85. <StyledDropdownIcon danger>
  86. <IconDelete size="sm" />
  87. </StyledDropdownIcon>
  88. </Button>
  89. </Tooltip>
  90. </Fragment>
  91. ),
  92. })),
  93. [scratchpads, isDefault, organization]
  94. );
  95. const selectedScratchpad = scratchpads.selected
  96. ? scratchpads.all[scratchpads.selected]
  97. : undefined;
  98. return (
  99. <ScratchpadGroup>
  100. <Button
  101. icon={<IconDashboard />}
  102. onClick={() => {
  103. Sentry.metrics.increment('ddm.scratchpad.dashboard');
  104. createDashboard(selectedScratchpad);
  105. }}
  106. >
  107. {t('Add to Dashboard')}
  108. </Button>
  109. <SaveAsDropdown
  110. onSave={name => {
  111. scratchpads.add(name);
  112. }}
  113. mode={scratchpads.selected ? 'fork' : 'save'}
  114. />
  115. <CompactSelect
  116. grid
  117. options={scratchpadOptions}
  118. value={scratchpads.selected ?? `None`}
  119. closeOnSelect={false}
  120. onChange={option => {
  121. scratchpads.toggleSelected(option.value);
  122. }}
  123. triggerProps={{prefix: t('Scratchpad')}}
  124. emptyMessage="No scratchpads yet."
  125. disabled={false}
  126. />
  127. </ScratchpadGroup>
  128. );
  129. }
  130. function SaveAsDropdown({
  131. onSave,
  132. mode,
  133. }: {
  134. mode: 'save' | 'fork';
  135. onSave: (name: string) => void;
  136. }) {
  137. const {
  138. isOpen,
  139. triggerProps,
  140. overlayProps,
  141. arrowProps,
  142. state: {setOpen},
  143. } = useOverlay({});
  144. const theme = useTheme();
  145. const organization = useOrganization();
  146. const [name, setName] = useState('');
  147. const save = useCallback(() => {
  148. trackAnalytics('ddm.scratchpad.save', {
  149. organization,
  150. });
  151. Sentry.metrics.increment('ddm.scratchpad.save');
  152. onSave(name);
  153. setOpen(false);
  154. setName('');
  155. }, [name, onSave, setOpen, organization]);
  156. const enterKeyPressed = useKeyPress('Enter');
  157. useEffect(() => {
  158. if (isOpen && enterKeyPressed && name) {
  159. save();
  160. }
  161. }, [enterKeyPressed, isOpen, name, save]);
  162. const isFork = mode === 'fork';
  163. return (
  164. <div>
  165. <Button icon={isFork ? null : <IconStar isSolid={isFork} />} {...triggerProps}>
  166. {isFork ? `${t('Duplicate as')}\u2026` : `${t('Save as')}\u2026`}
  167. </Button>
  168. <AnimatePresence>
  169. {isOpen && (
  170. <FocusScope contain restoreFocus autoFocus>
  171. <PositionWrapper zIndex={theme.zIndex.dropdown} {...overlayProps}>
  172. <StyledOverlay arrowProps={arrowProps} animated>
  173. <SaveAsInput
  174. type="txt"
  175. name="scratchpad-name"
  176. placeholder={t('Scratchpad name')}
  177. value={name}
  178. size="sm"
  179. onChange={({target}) => setName(target.value)}
  180. />
  181. <GuideAnchor target="create_scratchpad" position="bottom">
  182. <SaveAsButton
  183. priority="primary"
  184. disabled={!name}
  185. onClick={() => {
  186. save();
  187. }}
  188. >
  189. {mode === 'fork' ? t('Duplicate') : t('Save')}
  190. </SaveAsButton>
  191. </GuideAnchor>
  192. </StyledOverlay>
  193. </PositionWrapper>
  194. </FocusScope>
  195. )}
  196. </AnimatePresence>
  197. </div>
  198. );
  199. }
  200. const ScratchpadGroup = styled('div')`
  201. display: flex;
  202. gap: ${space(1)};
  203. `;
  204. const StyledOverlay = styled(Overlay)`
  205. padding: ${space(1)};
  206. `;
  207. const SaveAsButton = styled(Button)`
  208. width: 100%;
  209. `;
  210. const SaveAsInput = styled(InputControl)`
  211. margin-bottom: ${space(1)};
  212. `;
  213. const StyledDropdownIcon = styled('span')<{danger?: boolean}>`
  214. padding: ${space(0.5)} ${space(0.5)} 0 ${space(0.5)};
  215. opacity: 0.5;
  216. :hover {
  217. opacity: 0.9;
  218. color: ${p => (p.danger ? p.theme.red300 : p.theme.gray300)};
  219. }
  220. `;