shareModal.tsx 6.4 KB

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