Просмотр исходного кода

feat(issues): Move share feature into a modal (#39781)

Scott Cooper 2 лет назад
Родитель
Сommit
7600cee3d5

+ 14 - 4
static/app/components/globalModal/components.tsx

@@ -8,8 +8,13 @@ import space from 'sentry/styles/space';
 const ModalHeader = styled('header')`
   position: relative;
   border-bottom: 1px solid ${p => p.theme.border};
-  padding: ${space(3)} ${space(4)};
-  margin: -${space(4)} -${space(4)} ${space(3)} -${space(4)};
+  padding: ${space(3)} ${space(3)};
+  margin: -${space(4)} -${space(2)} ${space(3)} -${space(3)};
+
+  @media (min-width: ${p => p.theme.breakpoints.medium}) {
+    padding: ${space(3)} ${space(4)};
+    margin: -${space(4)} -${space(4)} ${space(3)} -${space(4)};
+  }
 
   h1,
   h2,
@@ -55,8 +60,13 @@ const ModalFooter = styled('footer')`
   border-top: 1px solid ${p => p.theme.border};
   display: flex;
   justify-content: flex-end;
-  padding: ${space(3)} ${space(4)};
-  margin: ${space(3)} -${space(4)} -${space(4)};
+  padding: ${space(3)} ${space(2)};
+  margin: ${space(3)} -${space(3)} -${space(4)};
+
+  @media (min-width: ${p => p.theme.breakpoints.medium}) {
+    padding: ${space(3)} ${space(4)};
+    margin: ${space(3)} -${space(4)} -${space(4)};
+  }
 `;
 
 interface ClosableHeaderProps extends React.HTMLAttributes<HTMLHeadingElement> {

+ 11 - 2
static/app/components/globalModal/index.tsx

@@ -231,9 +231,14 @@ const Container = styled('div')`
 `;
 
 const Modal = styled(motion.div)`
+  max-width: 100%;
   width: 640px;
   pointer-events: auto;
-  padding: 80px ${space(2)} ${space(4)} ${space(2)};
+  padding: 80px ${space(1.5)} ${space(2)} ${space(1.5)};
+
+  @media (min-width: ${p => p.theme.breakpoints.medium}) {
+    padding: 80px ${space(2)} ${space(4)} ${space(2)};
+  }
 `;
 
 Modal.defaultProps = {
@@ -247,11 +252,15 @@ Modal.defaultProps = {
 };
 
 const Content = styled('div')`
-  padding: ${space(4)};
   background: ${p => p.theme.background};
   border-radius: 8px;
   box-shadow: 0 0 0 1px ${p => p.theme.translucentBorder}, ${p => p.theme.dropShadowHeavy};
   position: relative;
+  padding: ${space(4)} ${space(3)};
+
+  @media (min-width: ${p => p.theme.breakpoints.medium}) {
+    padding: ${space(4)};
+  }
 `;
 
 type State = {

+ 5 - 54
static/app/views/organizationGroupDetails/actions/index.tsx

@@ -4,11 +4,7 @@ import styled from '@emotion/styled';
 import {Query} from 'history';
 
 import {bulkDelete, bulkUpdate} from 'sentry/actionCreators/group';
-import {
-  addErrorMessage,
-  addLoadingMessage,
-  clearIndicators,
-} from 'sentry/actionCreators/indicator';
+import {addLoadingMessage, clearIndicators} from 'sentry/actionCreators/indicator';
 import {
   ModalRenderProps,
   openModal,
@@ -61,21 +57,7 @@ type Props = {
   query?: Query;
 };
 
-type State = {
-  shareBusy: boolean;
-};
-
-class Actions extends Component<Props, State> {
-  state: State = {
-    shareBusy: false,
-  };
-
-  componentWillReceiveProps(nextProps: Props) {
-    if (this.state.shareBusy && nextProps.group.shareId !== this.props.group.shareId) {
-      this.setState({shareBusy: false});
-    }
-  }
-
+class Actions extends Component<Props> {
   getShareUrl(shareId: string) {
     if (!shareId) {
       return '';
@@ -193,35 +175,6 @@ class Actions extends Component<Props, State> {
     openReprocessEventModal({organization, groupId: group.id});
   };
 
-  onShare(shared: boolean) {
-    const {group, project, organization, api} = this.props;
-    this.setState({shareBusy: true});
-
-    // not sure why this is a bulkUpdate
-    bulkUpdate(
-      api,
-      {
-        orgId: organization.slug,
-        projectId: project.slug,
-        itemIds: [group.id],
-        data: {
-          isPublic: shared,
-        },
-      },
-      {
-        error: () => {
-          addErrorMessage(t('Error sharing'));
-        },
-        complete: () => {
-          // shareBusy marked false in componentWillReceiveProps to sync
-          // busy state update with shareId update
-        },
-      }
-    );
-
-    this.trackIssueAction('shared');
-  }
-
   onToggleShare = () => {
     const newIsPublic = !this.props.group.isPublic;
     if (newIsPublic) {
@@ -229,7 +182,7 @@ class Actions extends Component<Props, State> {
         organization: this.props.organization,
       });
     }
-    this.onShare(newIsPublic);
+    this.trackIssueAction('shared');
   };
 
   onToggleBookmark = () => {
@@ -417,13 +370,11 @@ class Actions extends Component<Props, State> {
         </Feature>
         {orgFeatures.has('shared-issues') && (
           <ShareIssue
+            organization={organization}
+            group={group}
             disabled={disabled || !shareCap.enabled}
             disabledReason={shareCap.disabledReason}
-            loading={this.state.shareBusy}
-            isShared={group.isPublic}
-            shareUrl={this.getShareUrl(group.shareId)}
             onToggle={this.onToggleShare}
-            onReshare={() => this.onShare(true)}
           />
         )}
         <SubscribeAction

+ 36 - 214
static/app/views/organizationGroupDetails/actions/shareIssue.tsx

@@ -1,244 +1,66 @@
-import {useRef, useState} from 'react';
 import styled from '@emotion/styled';
 
-import ActionButton from 'sentry/components/actions/button';
-import AutoSelectText from 'sentry/components/autoSelectText';
+import {openModal} from 'sentry/actionCreators/modal';
 import Button from 'sentry/components/button';
-import Clipboard from 'sentry/components/clipboard';
-import Confirm from 'sentry/components/confirm';
-import DropdownLink from 'sentry/components/dropdownLink';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
-import Switch from 'sentry/components/switchButton';
 import Tooltip from 'sentry/components/tooltip';
-import {IconChevron, IconCopy, IconRefresh} from 'sentry/icons';
 import {t} from 'sentry/locale';
-import space from 'sentry/styles/space';
+import type {Group, Organization} from 'sentry/types';
 
-type ContainerProps = {
-  onCancel: () => void;
-  onConfirm: () => void;
-  onConfirming: () => void;
-  shareUrl: string;
-};
+import ShareIssueModal from './shareModal';
 
-type Props = {
-  loading: boolean;
-  /**
-   * Called when refreshing an existing link
-   */
-  onReshare: () => void;
+interface ShareIssueProps {
+  group: Group;
   onToggle: () => void;
+  organization: Organization;
   disabled?: boolean;
   disabledReason?: string;
-  /**
-   * Link is public
-   */
-  isShared?: boolean;
-  shareUrl?: string | null;
-};
+}
 
 function ShareIssue({
-  loading,
-  onReshare,
   onToggle,
   disabled,
+  group,
+  organization,
   disabledReason,
-  isShared,
-  shareUrl,
-}: Props) {
-  const [hasConfirmModal, setHasConfirmModal] = useState(false);
-
-  // State of confirm modal so we can keep dropdown menu opn
-  const handleConfirmCancel = () => {
-    setHasConfirmModal(false);
-  };
-
-  const handleConfirmReshare = () => {
-    setHasConfirmModal(true);
-  };
-
-  const handleToggleShare = (e: React.MouseEvent<HTMLButtonElement>) => {
-    e.preventDefault();
-    onToggle();
-  };
-
+}: ShareIssueProps) {
   const handleOpen = () => {
     // Starts sharing as soon as dropdown is opened
-    if (!loading && !isShared) {
-      onToggle();
-    }
+    openModal(modalProps => (
+      <ShareIssueModal
+        {...modalProps}
+        organization={organization}
+        projectSlug={group.project.slug}
+        groupId={group.id}
+        onToggle={onToggle}
+      />
+    ));
   };
 
-  const renderDropdown = () => (
-    <DropdownLink
-      shouldIgnoreClickOutside={() => hasConfirmModal}
-      customTitle={
-        <ActionButton disabled={disabled}>
-          <DropdownTitleContent>
-            <IndicatorDot isShared={isShared} />
-            {t('Share')}
-          </DropdownTitleContent>
-
-          <IconChevron direction="down" size="xs" />
-        </ActionButton>
-      }
-      onOpen={handleOpen}
-      disabled={disabled}
-      keepMenuOpen
-    >
-      <DropdownContent>
-        <Header>
-          <Title>{t('Enable public share link')}</Title>
-          <Switch isActive={isShared} size="sm" toggle={handleToggleShare} />
-        </Header>
-
-        {loading && (
-          <LoadingContainer>
-            <LoadingIndicator mini />
-          </LoadingContainer>
-        )}
-
-        {!loading && isShared && shareUrl && (
-          <ShareUrlContainer
-            shareUrl={shareUrl}
-            onCancel={handleConfirmCancel}
-            onConfirming={handleConfirmReshare}
-            onConfirm={onReshare}
-          />
-        )}
-      </DropdownContent>
-    </DropdownLink>
-  );
-
-  return disabled ? (
-    <Tooltip title={disabledReason}>{renderDropdown()}</Tooltip>
-  ) : (
-    renderDropdown()
-  );
-}
-
-export default ShareIssue;
-
-type UrlRef = React.ElementRef<typeof AutoSelectText>;
-
-function ShareUrlContainer({
-  shareUrl,
-  onConfirming,
-  onCancel,
-  onConfirm,
-}: ContainerProps) {
-  const urlRef = useRef<UrlRef>(null);
-
   return (
-    <UrlContainer>
-      <TextContainer>
-        <StyledAutoSelectText ref={urlRef}>{shareUrl}</StyledAutoSelectText>
-      </TextContainer>
-
-      <Clipboard hideUnsupported value={shareUrl}>
-        <ClipboardButton
-          title={t('Copy to clipboard')}
-          borderless
-          size="xs"
-          onClick={() => urlRef.current?.selectText()}
-          icon={<IconCopy />}
-          aria-label={t('Copy to clipboard')}
-        />
-      </Clipboard>
-
-      <Confirm
-        message={t(
-          'You are about to regenerate a new shared URL. Your previously shared URL will no longer work. Do you want to continue?'
-        )}
-        onCancel={onCancel}
-        onConfirming={onConfirming}
-        onConfirm={onConfirm}
+    <Tooltip title={disabledReason} disabled={!disabled}>
+      <Button
+        type="button"
+        size="xs"
+        onClick={handleOpen}
+        disabled={disabled}
+        icon={
+          <IndicatorDot
+            aria-label={group.isPublic ? t('Shared') : t('Not Shared')}
+            isShared={group.isPublic}
+          />
+        }
       >
-        <ReshareButton
-          title={t('Generate new URL')}
-          aria-label={t('Generate new URL')}
-          borderless
-          size="xs"
-          icon={<IconRefresh />}
-        />
-      </Confirm>
-    </UrlContainer>
+        {t('Share')}
+      </Button>
+    </Tooltip>
   );
 }
 
-const UrlContainer = styled('div')`
-  display: flex;
-  align-items: stretch;
-  border: 1px solid ${p => p.theme.border};
-  border-radius: ${space(0.5)};
-`;
-
-const LoadingContainer = styled('div')`
-  display: flex;
-  justify-content: center;
-`;
-
-const DropdownTitleContent = styled('div')`
-  display: flex;
-  align-items: center;
-  margin-right: ${space(0.5)};
-`;
-
-const DropdownContent = styled('li')`
-  padding: ${space(1.5)} ${space(2)};
-
-  > div:not(:last-of-type) {
-    margin-bottom: ${space(1.5)};
-  }
-`;
-
-const Header = styled('div')`
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-`;
-
-const Title = styled('div')`
-  padding-right: ${space(4)};
-  white-space: nowrap;
-  font-size: ${p => p.theme.fontSizeMedium};
-  font-weight: 600;
-`;
+export default ShareIssue;
 
-const IndicatorDot = styled('span')<{isShared?: boolean}>`
-  display: inline-block;
-  margin-right: ${space(0.5)};
+const IndicatorDot = styled('div')<{isShared?: boolean}>`
   border-radius: 50%;
   width: 10px;
   height: 10px;
   background: ${p => (p.isShared ? p.theme.active : p.theme.border)};
 `;
-
-const StyledAutoSelectText = styled(AutoSelectText)`
-  flex: 1;
-  padding: ${space(0.5)} 0 ${space(0.5)} ${space(0.75)};
-  ${p => p.theme.overflowEllipsis}
-`;
-
-const TextContainer = styled('div')`
-  position: relative;
-  display: flex;
-  flex: 1;
-  background-color: transparent;
-  border-right: 1px solid ${p => p.theme.border};
-  max-width: 288px;
-`;
-
-const ClipboardButton = styled(Button)`
-  border-radius: 0;
-  border-right: 1px solid ${p => p.theme.border};
-  height: 100%;
-
-  &:hover {
-    border-right: 1px solid ${p => p.theme.border};
-  }
-`;
-
-const ReshareButton = styled(Button)`
-  height: 100%;
-`;

+ 77 - 0
static/app/views/organizationGroupDetails/actions/shareModal.spec.tsx

@@ -0,0 +1,77 @@
+import {renderGlobalModal, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {openModal} from 'sentry/actionCreators/modal';
+import GroupStore from 'sentry/stores/groupStore';
+import ModalStore from 'sentry/stores/modalStore';
+import ShareIssueModal from 'sentry/views/organizationGroupDetails/actions/shareModal';
+
+describe('shareModal', () => {
+  const project = TestStubs.Project();
+  const organization = TestStubs.Organization();
+  const onToggle = jest.fn();
+
+  beforeEach(() => {
+    GroupStore.init();
+  });
+  afterEach(() => {
+    ModalStore.reset();
+    GroupStore.reset();
+    MockApiClient.clearMockResponses();
+    jest.clearAllMocks();
+  });
+
+  it('should share on open', async () => {
+    const group = TestStubs.Group();
+    GroupStore.add([group]);
+
+    const issuesApi = MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/issues/`,
+      method: 'PUT',
+      body: {...group, isPublic: true, shareId: '12345'},
+    });
+    renderGlobalModal();
+
+    openModal(modalProps => (
+      <ShareIssueModal
+        {...modalProps}
+        groupId={group.id}
+        organization={organization}
+        projectSlug={project.slug}
+        onToggle={onToggle}
+      />
+    ));
+
+    expect(screen.getByText('Share Issue')).toBeInTheDocument();
+    expect(await screen.findByRole('button', {name: 'Copy Link'})).toBeInTheDocument();
+    expect(issuesApi).toHaveBeenCalledTimes(1);
+    expect(onToggle).toHaveBeenCalledTimes(1);
+  });
+
+  it('should unshare', async () => {
+    const group = TestStubs.Group({isPublic: true, shareId: '12345'});
+    GroupStore.add([group]);
+
+    const issuesApi = MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/issues/`,
+      method: 'PUT',
+      body: {...group, isPublic: false, shareId: null},
+    });
+    renderGlobalModal();
+
+    openModal(modalProps => (
+      <ShareIssueModal
+        {...modalProps}
+        groupId={group.id}
+        organization={organization}
+        projectSlug={project.slug}
+        onToggle={onToggle}
+      />
+    ));
+
+    userEvent.click(screen.getByLabelText('Unshare'));
+
+    expect(await screen.findByRole('button', {name: 'Close'})).toBeInTheDocument();
+    expect(issuesApi).toHaveBeenCalledTimes(1);
+    expect(onToggle).toHaveBeenCalledTimes(1);
+  });
+});

+ 248 - 0
static/app/views/organizationGroupDetails/actions/shareModal.tsx

@@ -0,0 +1,248 @@
+import {Fragment, useCallback, useEffect, useRef, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {bulkUpdate} from 'sentry/actionCreators/group';
+import {addErrorMessage} from 'sentry/actionCreators/indicator';
+import {closeModal, ModalRenderProps} from 'sentry/actionCreators/modal';
+import AutoSelectText from 'sentry/components/autoSelectText';
+import Button from 'sentry/components/button';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import Switch from 'sentry/components/switchButton';
+import {IconCopy, IconRefresh} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import GroupStore from 'sentry/stores/groupStore';
+import {useLegacyStore} from 'sentry/stores/useLegacyStore';
+import space from 'sentry/styles/space';
+import type {Group, Organization} from 'sentry/types';
+import useApi from 'sentry/utils/useApi';
+
+interface ShareIssueModalProps extends ModalRenderProps {
+  groupId: string;
+  onToggle: () => void;
+  organization: Organization;
+  projectSlug: string;
+  disabled?: boolean;
+  disabledReason?: string;
+}
+
+type UrlRef = React.ElementRef<typeof AutoSelectText>;
+
+function ShareIssueModal({
+  Header,
+  Body,
+  Footer,
+  organization,
+  projectSlug,
+  groupId,
+  onToggle,
+}: ShareIssueModalProps) {
+  const api = useApi({persistInFlight: true});
+  const [loading, setLoading] = useState(false);
+  const urlRef = useRef<UrlRef>(null);
+  const groups = useLegacyStore(GroupStore);
+  const group = (groups as Group[]).find(item => item.id === groupId)!;
+  const isShared = group.isPublic;
+
+  function getShareUrl() {
+    const path = `/share/issue/${group.shareId}/`;
+    const {host, protocol} = window.location;
+    return `${protocol}//${host}${path}`;
+  }
+
+  const shareUrl = group.shareId ? getShareUrl() : null;
+
+  const handleShare = useCallback(
+    (e: React.MouseEvent<HTMLButtonElement> | null, reshare?: boolean) => {
+      e?.preventDefault();
+      setLoading(true);
+      onToggle();
+
+      bulkUpdate(
+        api,
+        {
+          orgId: organization.slug,
+          projectId: projectSlug,
+          itemIds: [groupId],
+          data: {
+            isPublic: reshare ?? !isShared,
+          },
+        },
+        {
+          error: () => {
+            addErrorMessage(t('Error sharing'));
+          },
+          complete: () => {
+            setLoading(false);
+          },
+        }
+      );
+    },
+    [api, setLoading, onToggle, isShared, organization.slug, projectSlug, groupId]
+  );
+
+  /**
+   * Share as soon as modal is opened
+   */
+  useEffect(() => {
+    if (isShared) {
+      return;
+    }
+
+    handleShare(null, true);
+    // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want to run this on open
+  }, []);
+
+  return (
+    <Fragment>
+      <Header closeButton>
+        <h4>{t('Share Issue')}</h4>
+      </Header>
+      <Body>
+        <ModalContent>
+          <SwitchWrapper>
+            <div>
+              <Title>{t('Create a public link')}</Title>
+              <SubText>{t('Share a link with anyone outside your organization')}</SubText>
+            </div>
+            <Switch
+              aria-label={isShared ? t('Unshare') : t('Share')}
+              isActive={isShared}
+              size="lg"
+              toggle={handleShare}
+            />
+          </SwitchWrapper>
+
+          {loading && (
+            <LoadingContainer>
+              <LoadingIndicator mini />
+            </LoadingContainer>
+          )}
+
+          {!loading && isShared && shareUrl && (
+            <UrlContainer>
+              <TextContainer>
+                <StyledAutoSelectText ref={urlRef}>{shareUrl}</StyledAutoSelectText>
+              </TextContainer>
+
+              <ClipboardButton
+                title={t('Copy to clipboard')}
+                borderless
+                size="sm"
+                onClick={() => {
+                  navigator.clipboard.writeText(shareUrl);
+                  urlRef.current?.selectText();
+                }}
+                icon={<IconCopy />}
+                aria-label={t('Copy to clipboard')}
+              />
+
+              <ReshareButton
+                title={t('Generate new URL. Invalidates previous URL')}
+                aria-label={t('Generate new URL')}
+                borderless
+                size="sm"
+                icon={<IconRefresh />}
+                onClick={() => handleShare(null, true)}
+              />
+            </UrlContainer>
+          )}
+        </ModalContent>
+      </Body>
+      <Footer>
+        {!loading && isShared && shareUrl ? (
+          <Button
+            type="button"
+            priority="primary"
+            onClick={() => {
+              navigator.clipboard.writeText(shareUrl);
+              closeModal();
+            }}
+          >
+            {t('Copy Link')}
+          </Button>
+        ) : (
+          <Button
+            type="button"
+            priority="primary"
+            onClick={() => {
+              closeModal();
+            }}
+          >
+            {t('Close')}
+          </Button>
+        )}
+      </Footer>
+    </Fragment>
+  );
+}
+
+export default ShareIssueModal;
+
+/**
+ * min-height reduces layout shift when switching on and off
+ */
+const ModalContent = styled('div')`
+  display: flex;
+  gap: ${space(2)};
+  flex-direction: column;
+  min-height: 100px;
+`;
+
+const SwitchWrapper = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: ${space(2)};
+`;
+
+const Title = styled('div')`
+  padding-right: ${space(4)};
+  white-space: nowrap;
+`;
+
+const SubText = styled('p')`
+  color: ${p => p.theme.subText};
+  font-size: ${p => p.theme.fontSizeSmall};
+`;
+
+const LoadingContainer = styled('div')`
+  display: flex;
+  justify-content: center;
+`;
+
+const UrlContainer = styled('div')`
+  display: flex;
+  align-items: stretch;
+  border: 1px solid ${p => p.theme.border};
+  border-radius: ${space(0.5)};
+`;
+
+const StyledAutoSelectText = styled(AutoSelectText)`
+  padding: ${space(1)} ${space(1)};
+  ${p => p.theme.overflowEllipsis}
+`;
+
+const TextContainer = styled('div')`
+  position: relative;
+  display: flex;
+  flex-grow: 1;
+  background-color: transparent;
+  border-right: 1px solid ${p => p.theme.border};
+  min-width: 0;
+`;
+
+const ClipboardButton = styled(Button)`
+  border-radius: 0;
+  border-right: 1px solid ${p => p.theme.border};
+  height: 100%;
+  flex-shrink: 0;
+
+  &:hover {
+    border-right: 1px solid ${p => p.theme.border};
+  }
+`;
+
+const ReshareButton = styled(Button)`
+  height: 100%;
+  flex-shrink: 0;
+`;