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

feat(mobile-exp): Add card interactions to screenshots (#39927)

1. Clicking on event id should take user to event detail
2. Clicking on the card should open the modal
3. Context menu has options for delete and download

Note the EventAttachment serializer was updated to
return event_id in https://github.com/getsentry/sentry/pull/39881.
This means there is no longer a difference in EventAttachment
and IssueAttachment types. Will clean up references in
a follow up.
Shruthi 2 лет назад
Родитель
Сommit
14be49f528

+ 16 - 0
fixtures/js-stubs/eventAttachment.js

@@ -0,0 +1,16 @@
+export function EventAttachment(params = {}) {
+  return {
+    id: '1',
+    name: 'screenshot.png',
+    headers: {
+      'Content-Type': 'image/png',
+    },
+    mimetype: 'image/png',
+    size: 84235,
+    sha1: '986043ce8056f3cde048720d30a3959a6692fbef',
+    dateCreated: '2022-09-12T09:27:30.512445Z',
+    type: 'event.attachment',
+    event_id: '12345678901234567890123456789012',
+    ...params,
+  };
+}

+ 1 - 0
fixtures/js-stubs/types.tsx

@@ -35,6 +35,7 @@ type TestStubFixtures = {
   Entries: SimpleStub;
   Environments: OverridableStub;
   Event: OverridableStub;
+  EventAttachment: OverridableStub;
   EventEntry: OverridableStub;
   EventEntryDebugMeta: OverridableStub;
   EventEntryStacktrace: OverridableStub;

+ 1 - 3
static/app/components/events/attachmentViewers/imageViewer.tsx

@@ -7,9 +7,7 @@ import {
 import {PanelItem} from 'sentry/components/panels';
 
 type Props = Omit<ViewerProps, 'attachment'> & {
-  attachment: Omit<ViewerProps['attachment'], 'event_id'> & {
-    event_id?: string;
-  };
+  attachment: ViewerProps['attachment'];
   onError?: React.ReactEventHandler<HTMLImageElement>;
   onLoad?: React.ReactEventHandler<HTMLImageElement>;
 };

+ 3 - 1
static/app/components/events/eventTagsAndScreenshot/index.spec.tsx

@@ -153,7 +153,7 @@ describe('EventTagsAndScreenshot', function () {
     },
   } as Parameters<typeof initializeOrg>[0]);
 
-  const attachments: Omit<EventAttachment, 'event_id'>[] = [
+  const attachments: EventAttachment[] = [
     {
       id: '1765467044',
       name: 'log.txt',
@@ -163,6 +163,7 @@ describe('EventTagsAndScreenshot', function () {
       sha1: 'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d',
       dateCreated: '2021-08-31T15:14:53.113630Z',
       type: 'event.attachment',
+      event_id: 'bbf4c61ddaa7d8b2dbbede0f3b482cd9beb9434d',
     },
     {
       id: '1765467046',
@@ -173,6 +174,7 @@ describe('EventTagsAndScreenshot', function () {
       sha1: '657eae9c13474518a6d0175bd4ab6bb4f81bf40e',
       dateCreated: '2021-08-31T15:14:53.130940Z',
       type: 'event.attachment',
+      event_id: 'bbf4c61ddaa7d8b2dbbede0f3b482cd9beb9434d',
     },
   ];
 

+ 8 - 7
static/app/components/events/eventTagsAndScreenshot/screenshot/modal.tsx

@@ -20,11 +20,11 @@ import ImageVisualization from './imageVisualization';
 
 type Props = ModalRenderProps & {
   downloadUrl: string;
-  event: Event;
   eventAttachment: EventAttachment;
   onDelete: () => void;
   orgSlug: Organization['slug'];
   projectSlug: Project['slug'];
+  event?: Event;
 };
 
 function Modal({
@@ -54,11 +54,12 @@ function Modal({
                     fixed: new Date(1508208080000),
                   })}
                 />
-                {getRelativeTimeFromEventDateCreated(
-                  event.dateCreated ? event.dateCreated : event.dateReceived,
-                  dateCreated,
-                  false
-                )}
+                {event &&
+                  getRelativeTimeFromEventDateCreated(
+                    event.dateCreated ? event.dateCreated : event.dateReceived,
+                    dateCreated,
+                    false
+                  )}
               </Fragment>
             ) : (
               <NotAvailable />
@@ -76,7 +77,7 @@ function Modal({
           attachment={eventAttachment}
           orgId={orgSlug}
           projectId={projectSlug}
-          eventId={event.id}
+          eventId={eventAttachment.event_id}
         />
       </Body>
       <Footer>

+ 1 - 1
static/app/types/group.tsx

@@ -102,7 +102,7 @@ export type IssueAttachment = {
 };
 
 // endpoint: /api/0/projects/:orgSlug/:projSlug/events/:eventId/attachments/
-export type EventAttachment = Omit<IssueAttachment, 'event_id'>;
+export type EventAttachment = IssueAttachment;
 
 /**
  * Issue Tags

+ 37 - 25
static/app/views/organizationGroupDetails/groupEventAttachments/groupEventAttachments.spec.tsx

@@ -1,11 +1,14 @@
 import {initializeOrg} from 'sentry-test/initializeOrg';
-import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
 
+import {openModal} from 'sentry/actionCreators/modal';
 import GroupStore from 'sentry/stores/groupStore';
 import ProjectsStore from 'sentry/stores/projectsStore';
 
 import GroupEventAttachments from './groupEventAttachments';
 
+jest.mock('sentry/actionCreators/modal');
+
 describe('GroupEventAttachments > Screenshots', function () {
   const {organization, routerContext} = initializeOrg({
     organization: TestStubs.Organization({features: ['mobile-screenshot-gallery']}),
@@ -24,21 +27,7 @@ describe('GroupEventAttachments > Screenshots', function () {
 
     getAttachmentsMock = MockApiClient.addMockResponse({
       url: '/issues/group-id/attachments/',
-      body: [
-        {
-          id: '3808101758',
-          name: 'screenshot.png',
-          headers: {
-            'Content-Type': 'image/png',
-          },
-          mimetype: 'image/png',
-          size: 84235,
-          sha1: '207960ce8056f3cde048720d30a3959a6692fbef',
-          dateCreated: '2022-09-12T09:27:30.512445Z',
-          type: 'event.attachment',
-          event_id: '55cc19fb18114dd88cc91a5f29ccbd10',
-        },
-      ],
+      body: [TestStubs.EventAttachment()],
     });
   });
 
@@ -51,17 +40,40 @@ describe('GroupEventAttachments > Screenshots', function () {
     });
   }
 
-  it('calls attachments api with screenshot filter', async function () {
+  it('calls attachments api with screenshot filter', function () {
     renderGroupEventAttachments();
     expect(screen.getByText('Screenshots')).toBeInTheDocument();
     userEvent.click(screen.getByText('Screenshots'));
-    await waitFor(() => {
-      expect(getAttachmentsMock).toHaveBeenCalledWith(
-        '/issues/group-id/attachments/',
-        expect.objectContaining({
-          query: {per_page: 6, screenshot: 1, types: undefined},
-        })
-      );
-    });
+    expect(getAttachmentsMock).toHaveBeenCalledWith(
+      '/issues/group-id/attachments/',
+      expect.objectContaining({
+        query: {per_page: 6, screenshot: 1, types: undefined},
+      })
+    );
+  });
+
+  it('calls opens modal when clicking on panel body', function () {
+    renderGroupEventAttachments();
+    userEvent.click(screen.getByTestId('screenshot-1'));
+    expect(openModal).toHaveBeenCalled();
+  });
+
+  it('links event id to event detail', function () {
+    renderGroupEventAttachments();
+    expect(
+      screen.getByText('12345678901234567890123456789012').closest('a')
+    ).toHaveAttribute(
+      'href',
+      '/organizations/org-slug/issues/group-id/events/12345678901234567890123456789012/'
+    );
+  });
+
+  it('links to the download URL', function () {
+    renderGroupEventAttachments();
+    userEvent.click(screen.getByLabelText('Actions'));
+    expect(screen.getByText('Download').closest('a')).toHaveAttribute(
+      'href',
+      '/api/0/projects/org-slug/project-slug/events/12345678901234567890123456789012/attachments/1/?download=1'
+    );
   });
 });

+ 3 - 1
static/app/views/organizationGroupDetails/groupEventAttachments/groupEventAttachments.tsx

@@ -183,7 +183,7 @@ class GroupEventAttachments extends AsyncComponent<Props, State> {
   }
   renderScreenshotGallery() {
     const {eventAttachments} = this.state;
-    const {projectSlug} = this.props;
+    const {projectSlug, params} = this.props;
 
     return (
       <ScreenshotGrid>
@@ -194,6 +194,8 @@ class GroupEventAttachments extends AsyncComponent<Props, State> {
               eventAttachment={screenshot}
               eventId={screenshot.event_id}
               projectSlug={projectSlug}
+              groupId={params.groupId}
+              onDelete={this.handleDelete}
             />
           );
         })}

+ 78 - 6
static/app/views/organizationGroupDetails/groupEventAttachments/screenshotCard.tsx

@@ -2,11 +2,21 @@ import {useState} from 'react';
 import LazyLoad from 'react-lazyload';
 import styled from '@emotion/styled';
 
+import {openModal} from 'sentry/actionCreators/modal';
+import MenuItemActionLink from 'sentry/components/actions/menuItemActionLink';
+import Button from 'sentry/components/button';
 import Card from 'sentry/components/card';
 import DateTime from 'sentry/components/dateTime';
+import DropdownLink from 'sentry/components/dropdownLink';
 import ImageVisualization from 'sentry/components/events/eventTagsAndScreenshot/screenshot/imageVisualization';
+import Modal, {
+  modalCss,
+} from 'sentry/components/events/eventTagsAndScreenshot/screenshot/modal';
+import Link from 'sentry/components/links/link';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {PanelBody} from 'sentry/components/panels';
+import {IconEllipsis} from 'sentry/icons/iconEllipsis';
+import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import {IssueAttachment, Project} from 'sentry/types';
 import useOrganization from 'sentry/utils/useOrganization';
@@ -14,24 +24,55 @@ import useOrganization from 'sentry/utils/useOrganization';
 type Props = {
   eventAttachment: IssueAttachment;
   eventId: string;
+  groupId: string;
+  onDelete: (attachmentId: string) => void;
   projectSlug: Project['slug'];
 };
 
-export function ScreenshotCard({eventAttachment, projectSlug, eventId}: Props) {
+export function ScreenshotCard({
+  eventAttachment,
+  projectSlug,
+  eventId,
+  groupId,
+  onDelete,
+}: Props) {
   const organization = useOrganization();
   const [loadingImage, setLoadingImage] = useState(true);
+
+  const downloadUrl = `/api/0/projects/${organization.slug}/${projectSlug}/events/${eventId}/attachments/${eventAttachment.id}/?download=1`;
+
+  function openVisualizationModal() {
+    openModal(
+      modalProps => (
+        <Modal
+          {...modalProps}
+          orgSlug={organization.slug}
+          projectSlug={projectSlug}
+          eventAttachment={eventAttachment}
+          downloadUrl={downloadUrl}
+          onDelete={() => onDelete(eventAttachment.id)}
+        />
+      ),
+      {modalCss}
+    );
+  }
+
+  const baseEventsPath = `/organizations/${organization.slug}/issues/${groupId}/events/`;
   return (
-    <Card interactive>
+    <Card>
       <CardHeader>
         <CardContent>
-          <Title>{eventId}</Title>
+          <Title to={`${baseEventsPath}${eventId}/`}>{eventId}</Title>
           <Detail>
             <DateTime date={eventAttachment.dateCreated} />
           </Detail>
         </CardContent>
       </CardHeader>
       <CardBody>
-        <StyledPanelBody>
+        <StyledPanelBody
+          onClick={() => openVisualizationModal()}
+          data-test-id={`screenshot-${eventAttachment.id}`}
+        >
           <LazyLoad>
             <StyledImageVisualization
               attachment={eventAttachment}
@@ -49,12 +90,40 @@ export function ScreenshotCard({eventAttachment, projectSlug, eventId}: Props) {
           </LazyLoad>
         </StyledPanelBody>
       </CardBody>
-      <CardFooter>{eventAttachment.name}</CardFooter>
+      <CardFooter>
+        <div>{eventAttachment.name}</div>
+        <DropdownLink
+          caret={false}
+          customTitle={
+            <Button
+              aria-label={t('Actions')}
+              size="xs"
+              icon={<IconEllipsis direction="down" size="sm" />}
+              borderless
+            />
+          }
+          anchorRight
+        >
+          <MenuItemActionLink shouldConfirm={false} href={`${downloadUrl}`}>
+            {t('Download')}
+          </MenuItemActionLink>
+          <MenuItemActionLink
+            shouldConfirm
+            confirmPriority="danger"
+            confirmLabel={t('Delete')}
+            onAction={() => onDelete(eventAttachment.id)}
+            header={t('This image was captured around the time that the event occurred.')}
+            message={t('Are you sure you wish to delete this image?')}
+          >
+            {t('Delete')}
+          </MenuItemActionLink>
+        </DropdownLink>
+      </CardFooter>
     </Card>
   );
 }
 
-const Title = styled('div')`
+const Title = styled(Link)`
   ${p => p.theme.overflowEllipsis};
   font-weight: normal;
 `;
@@ -86,6 +155,9 @@ const CardFooter = styled('div')`
   justify-content: space-between;
   align-items: center;
   padding: ${space(1)} ${space(2)};
+  .dropdown {
+    height: 24px;
+  }
 `;
 
 const CardContent = styled('div')`