shareModal.tsx 6.3 KB

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