Browse Source

feat(ui): Add copy link + copy markdown dropdown to short ID (#53825)

Looks like This


![image](https://github.com/getsentry/sentry/assets/1421724/7c251007-6e4c-4e5c-a7ca-3586b0c92073)

When not hovered there's nothing

![clipboard.png](https://i.imgur.com/8nzVGD4.png)
Evan Purkhiser 1 year ago
parent
commit
4ebc61d5b3

+ 4 - 37
static/app/views/issueDetails/header.tsx

@@ -3,7 +3,6 @@ import styled from '@emotion/styled';
 import {LocationDescriptor} from 'history';
 import omit from 'lodash/omit';
 
-import GuideAnchor from 'sentry/components/assistant/guideAnchor';
 import Badge from 'sentry/components/badge';
 import Breadcrumbs from 'sentry/components/breadcrumbs';
 import Count from 'sentry/components/count';
@@ -14,14 +13,11 @@ import EventMessage from 'sentry/components/events/eventMessage';
 import InboxReason from 'sentry/components/group/inboxBadges/inboxReason';
 import {GroupStatusBadge} from 'sentry/components/group/inboxBadges/statusBadge';
 import UnhandledInboxTag from 'sentry/components/group/inboxBadges/unhandledTag';
-import ProjectBadge from 'sentry/components/idBadge/projectBadge';
 import * as Layout from 'sentry/components/layouts/thirds';
 import Link from 'sentry/components/links/link';
 import ReplayCountBadge from 'sentry/components/replays/replayCountBadge';
 import useReplaysCount from 'sentry/components/replays/useReplaysCount';
-import ShortId from 'sentry/components/shortId';
 import {TabList} from 'sentry/components/tabs';
-import {Tooltip} from 'sentry/components/tooltip';
 import {IconChat} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
@@ -33,6 +29,7 @@ import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
 
 import GroupActions from './actions';
+import {ShortIdBreadrcumb} from './shortIdBreadcrumb';
 import {Tab} from './types';
 import {TagAndMessageWrapper} from './unhandledTag';
 import {ReprocessingStatus} from './utils';
@@ -227,26 +224,8 @@ function GroupHeader({
 
   const disableActions = !!disabledTabs.length;
 
-  const shortIdBreadCrumb = group.shortId && (
-    <GuideAnchor target="issue_number" position="bottom">
-      <ShortIdBreadrcumb>
-        <ProjectBadge
-          project={project}
-          avatarSize={16}
-          hideName
-          avatarProps={{hasTooltip: true, tooltip: project.slug}}
-        />
-        <Tooltip
-          className="help-link"
-          title={t(
-            'This identifier is unique across your organization, and can be used to reference an issue in various places, like commit messages.'
-          )}
-          position="bottom"
-        >
-          <StyledShortId shortId={group.shortId} />
-        </Tooltip>
-      </ShortIdBreadrcumb>
-    </GuideAnchor>
+  const shortIdBreadcrumb = (
+    <ShortIdBreadrcumb organization={organization} project={project} group={group} />
   );
 
   return (
@@ -259,7 +238,7 @@ function GroupHeader({
                 label: 'Issues',
                 to: `/organizations/${organization.slug}/issues/${location.search}`,
               },
-              {label: shortIdBreadCrumb},
+              {label: shortIdBreadcrumb},
             ]}
           />
           <GroupActions
@@ -335,18 +314,6 @@ const BreadcrumbActionWrapper = styled('div')`
   align-items: center;
 `;
 
-const ShortIdBreadrcumb = styled('div')`
-  display: flex;
-  gap: ${space(1)};
-  align-items: center;
-`;
-
-const StyledShortId = styled(ShortId)`
-  font-family: ${p => p.theme.text.family};
-  font-size: ${p => p.theme.fontSizeMedium};
-  line-height: 1;
-`;
-
 const HeaderRow = styled('div')`
   display: flex;
   gap: ${space(2)};

+ 46 - 0
static/app/views/issueDetails/shortIdBreadcrumb.spec.tsx

@@ -0,0 +1,46 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {ShortIdBreadrcumb} from './shortIdBreadcrumb';
+
+describe('ShortIdBreadrcumb', function () {
+  const {organization, project} = initializeOrg();
+  const group = TestStubs.Group({shortId: 'ABC-123'});
+
+  beforeEach(() => {
+    Object.assign(navigator, {
+      clipboard: {writeText: jest.fn().mockResolvedValue('')},
+    });
+  });
+
+  it('renders short ID', function () {
+    render(<ShortIdBreadrcumb {...{organization, project, group}} />);
+
+    expect(screen.getByText('ABC-123')).toBeInTheDocument();
+  });
+
+  it('supports copy', async function () {
+    render(<ShortIdBreadrcumb {...{organization, project, group}} />);
+
+    async function clickMenuItem(name: string) {
+      await userEvent.click(screen.getByRole('button', {name: 'Short-ID copy actions'}));
+      await userEvent.click(screen.getByRole('menuitemradio', {name}));
+    }
+
+    // Copy short ID
+    await clickMenuItem('Copy Short-ID');
+    expect(navigator.clipboard.writeText).toHaveBeenCalledWith('ABC-123');
+
+    // Copy short ID URL
+    await clickMenuItem('Copy Issue URL');
+    expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+      'http://localhost/organizations/org-slug/issues/1/'
+    );
+
+    // Copy short ID Markdown
+    await clickMenuItem('Copy Markdown Link');
+    expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+      '[ABC-123](http://localhost/organizations/org-slug/issues/1/)'
+    );
+  });
+});

+ 130 - 0
static/app/views/issueDetails/shortIdBreadcrumb.tsx

@@ -0,0 +1,130 @@
+import styled from '@emotion/styled';
+
+import GuideAnchor from 'sentry/components/assistant/guideAnchor';
+import {DropdownMenu} from 'sentry/components/dropdownMenu';
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import ShortId from 'sentry/components/shortId';
+import {Tooltip} from 'sentry/components/tooltip';
+import {IconChevron} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {Group, Organization, Project} from 'sentry/types';
+import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+
+interface ShortIdBreadrcumbProps {
+  group: Group;
+  organization: Organization;
+  project: Project;
+}
+
+export function ShortIdBreadrcumb({
+  organization,
+  project,
+  group,
+}: ShortIdBreadrcumbProps) {
+  const {onClick: handleCopyShortId} = useCopyToClipboard({
+    text: group.shortId,
+    successMessage: t('Copied Short-ID to clipboard'),
+  });
+
+  const issueUrl =
+    window.location.origin +
+    normalizeUrl(`/organizations/${organization.slug}/issues/${group.id}/`);
+
+  const {onClick: handleCopyUrl} = useCopyToClipboard({
+    text: issueUrl,
+    successMessage: t('Copied Issue URL to clipboard'),
+  });
+
+  const {onClick: handleCopyMarkdown} = useCopyToClipboard({
+    text: `[${group.shortId}](${issueUrl})`,
+    successMessage: t('Copied Markdown Issue Link to clipboard'),
+  });
+
+  if (!group.shortId) {
+    return null;
+  }
+
+  return (
+    <GuideAnchor target="issue_number" position="bottom">
+      <Wrapper>
+        <ProjectBadge
+          project={project}
+          avatarSize={16}
+          hideName
+          avatarProps={{hasTooltip: true, tooltip: project.slug}}
+        />
+        <ShortIdCopyable>
+          <Tooltip
+            className="help-link"
+            title={t(
+              'This identifier is unique across your organization, and can be used to reference an issue in various places, like commit messages.'
+            )}
+            position="bottom"
+            delay={1000}
+          >
+            <StyledShortId shortId={group.shortId} />
+          </Tooltip>
+          <DropdownMenu
+            triggerProps={{
+              'aria-label': t('Short-ID copy actions'),
+              icon: <IconChevron direction="down" size="xs" />,
+              size: 'zero',
+              borderless: true,
+              showChevron: false,
+            }}
+            position="bottom"
+            size="xs"
+            items={[
+              {
+                key: 'copy-url',
+                label: t('Copy Issue URL'),
+                onAction: handleCopyUrl,
+              },
+              {
+                key: 'copy-short-id',
+                label: t('Copy Short-ID'),
+                onAction: handleCopyShortId,
+              },
+              {
+                key: 'copy-markdown-link',
+                label: t('Copy Markdown Link'),
+                onAction: handleCopyMarkdown,
+              },
+            ]}
+          />
+        </ShortIdCopyable>
+      </Wrapper>
+    </GuideAnchor>
+  );
+}
+
+const Wrapper = styled('div')`
+  display: flex;
+  gap: ${space(1)};
+  align-items: center;
+`;
+
+const StyledShortId = styled(ShortId)`
+  font-family: ${p => p.theme.text.family};
+  font-size: ${p => p.theme.fontSizeMedium};
+  line-height: 1;
+`;
+
+const ShortIdCopyable = styled('div')`
+  display: flex;
+  gap: ${space(0.25)};
+  align-items: center;
+
+  button[aria-haspopup] {
+    opacity: 0;
+    transition: opacity 50ms linear;
+  }
+
+  &:hover button[aria-haspopup],
+  button[aria-expanded='true'],
+  button[aria-haspopup].focus-visible {
+    opacity: 1;
+  }
+`;