shareModal.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. import {Fragment, useCallback, useEffect, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import copy from 'copy-text-to-clipboard';
  4. import {bulkUpdate} from 'sentry/actionCreators/group';
  5. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  6. import {ModalRenderProps} from 'sentry/actionCreators/modal';
  7. import AutoSelectText from 'sentry/components/autoSelectText';
  8. import {Button} from 'sentry/components/button';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import Switch from 'sentry/components/switchButton';
  11. import {IconCopy, IconRefresh} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import GroupStore from 'sentry/stores/groupStore';
  14. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  15. import {space} from 'sentry/styles/space';
  16. import type {Group, Organization} from 'sentry/types';
  17. import useApi from 'sentry/utils/useApi';
  18. interface ShareIssueModalProps extends ModalRenderProps {
  19. groupId: string;
  20. onToggle: () => void;
  21. organization: Organization;
  22. projectSlug: string;
  23. disabled?: boolean;
  24. disabledReason?: string;
  25. }
  26. type UrlRef = React.ElementRef<typeof AutoSelectText>;
  27. function ShareIssueModal({
  28. Header,
  29. Body,
  30. Footer,
  31. organization,
  32. projectSlug,
  33. groupId,
  34. onToggle,
  35. closeModal,
  36. }: ShareIssueModalProps) {
  37. const api = useApi({persistInFlight: true});
  38. const [loading, setLoading] = useState(false);
  39. const urlRef = useRef<UrlRef>(null);
  40. const groups = useLegacyStore(GroupStore);
  41. const group = (groups as Group[]).find(item => item.id === groupId);
  42. const isShared = group?.isPublic;
  43. const handleShare = useCallback(
  44. (e: React.MouseEvent<HTMLButtonElement> | null, reshare?: boolean) => {
  45. e?.preventDefault();
  46. setLoading(true);
  47. onToggle();
  48. bulkUpdate(
  49. api,
  50. {
  51. orgId: organization.slug,
  52. projectId: projectSlug,
  53. itemIds: [groupId],
  54. data: {
  55. isPublic: reshare ?? !isShared,
  56. },
  57. },
  58. {
  59. error: () => {
  60. addErrorMessage(t('Error sharing'));
  61. },
  62. complete: () => {
  63. setLoading(false);
  64. },
  65. }
  66. );
  67. },
  68. [api, setLoading, onToggle, isShared, organization.slug, projectSlug, groupId]
  69. );
  70. /**
  71. * Share as soon as modal is opened
  72. */
  73. useEffect(() => {
  74. if (isShared) {
  75. return;
  76. }
  77. handleShare(null, true);
  78. // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want to run this on open
  79. }, []);
  80. function getShareUrl() {
  81. const path = `/share/issue/${group!.shareId}/`;
  82. const {host, protocol} = window.location;
  83. return `${protocol}//${host}${path}`;
  84. }
  85. const shareUrl = group?.shareId ? getShareUrl() : null;
  86. return (
  87. <Fragment>
  88. <Header closeButton>
  89. <h4>{t('Share Issue')}</h4>
  90. </Header>
  91. <Body>
  92. <ModalContent>
  93. <SwitchWrapper>
  94. <div>
  95. <Title>{t('Create a public link')}</Title>
  96. <SubText>{t('Share a link with anyone outside your organization')}</SubText>
  97. </div>
  98. <Switch
  99. aria-label={isShared ? t('Unshare') : t('Share')}
  100. isActive={isShared}
  101. size="lg"
  102. toggle={handleShare}
  103. />
  104. </SwitchWrapper>
  105. {(!group || loading) && (
  106. <LoadingContainer>
  107. <LoadingIndicator mini />
  108. </LoadingContainer>
  109. )}
  110. {group && !loading && isShared && shareUrl && (
  111. <UrlContainer>
  112. <TextContainer>
  113. <StyledAutoSelectText ref={urlRef}>{shareUrl}</StyledAutoSelectText>
  114. </TextContainer>
  115. <ClipboardButton
  116. title={t('Copy to clipboard')}
  117. borderless
  118. size="sm"
  119. onClick={() => {
  120. urlRef.current?.selectText();
  121. copy(shareUrl);
  122. }}
  123. icon={<IconCopy />}
  124. aria-label={t('Copy to clipboard')}
  125. />
  126. <ReshareButton
  127. title={t('Generate new URL. Invalidates previous URL')}
  128. aria-label={t('Generate new URL')}
  129. borderless
  130. size="sm"
  131. icon={<IconRefresh />}
  132. onClick={() => handleShare(null, true)}
  133. />
  134. </UrlContainer>
  135. )}
  136. </ModalContent>
  137. </Body>
  138. <Footer>
  139. {!loading && isShared && shareUrl ? (
  140. <Button
  141. priority="primary"
  142. onClick={() => {
  143. urlRef.current?.selectText();
  144. copy(shareUrl);
  145. closeModal();
  146. }}
  147. >
  148. {t('Copy Link')}
  149. </Button>
  150. ) : (
  151. <Button
  152. priority="primary"
  153. onClick={() => {
  154. closeModal();
  155. }}
  156. >
  157. {t('Close')}
  158. </Button>
  159. )}
  160. </Footer>
  161. </Fragment>
  162. );
  163. }
  164. export default ShareIssueModal;
  165. /**
  166. * min-height reduces layout shift when switching on and off
  167. */
  168. const ModalContent = styled('div')`
  169. display: flex;
  170. gap: ${space(2)};
  171. flex-direction: column;
  172. min-height: 100px;
  173. `;
  174. const SwitchWrapper = styled('div')`
  175. display: flex;
  176. justify-content: space-between;
  177. align-items: center;
  178. gap: ${space(2)};
  179. `;
  180. const Title = styled('div')`
  181. padding-right: ${space(4)};
  182. white-space: nowrap;
  183. `;
  184. const SubText = styled('p')`
  185. color: ${p => p.theme.subText};
  186. font-size: ${p => p.theme.fontSizeSmall};
  187. `;
  188. const LoadingContainer = styled('div')`
  189. display: flex;
  190. justify-content: center;
  191. `;
  192. const UrlContainer = styled('div')`
  193. display: flex;
  194. align-items: stretch;
  195. border: 1px solid ${p => p.theme.border};
  196. border-radius: ${space(0.5)};
  197. `;
  198. const StyledAutoSelectText = styled(AutoSelectText)`
  199. padding: ${space(1)} ${space(1)};
  200. ${p => p.theme.overflowEllipsis}
  201. `;
  202. const TextContainer = styled('div')`
  203. position: relative;
  204. display: flex;
  205. flex-grow: 1;
  206. background-color: transparent;
  207. border-right: 1px solid ${p => p.theme.border};
  208. min-width: 0;
  209. `;
  210. const ClipboardButton = styled(Button)`
  211. border-radius: 0;
  212. border-right: 1px solid ${p => p.theme.border};
  213. height: 100%;
  214. flex-shrink: 0;
  215. &:hover {
  216. border-right: 1px solid ${p => p.theme.border};
  217. }
  218. `;
  219. const ReshareButton = styled(Button)`
  220. height: 100%;
  221. flex-shrink: 0;
  222. `;