shareModal.tsx 6.3 KB

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