Browse Source

feat(mobile-exp): Paginate through single screenshots in gallery (#40260)

Paginate through single screenshots in gallery mode.
The pagination buttons are going to change based
on @olivier-w's designs, just want to get the functionality in.
Shruthi 2 years ago
parent
commit
88630de1b8

+ 142 - 0
static/app/components/events/eventTagsAndScreenshot/screenshot/modal.spec.tsx

@@ -0,0 +1,142 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {ModalRenderProps} from 'sentry/actionCreators/modal';
+import Modal from 'sentry/components/events/eventTagsAndScreenshot/screenshot/modal';
+import GroupStore from 'sentry/stores/groupStore';
+import ProjectsStore from 'sentry/stores/projectsStore';
+import {EventAttachment, Project} from 'sentry/types';
+
+const stubEl = (props: {children?: React.ReactNode}) => <div>{props.children}</div>;
+
+function renderModal({
+  initialData: {organization, routerContext},
+  eventAttachment,
+  projectSlug,
+  attachmentIndex,
+  attachments,
+  enablePagination,
+  groupId,
+}: {
+  eventAttachment: EventAttachment;
+  initialData: any;
+  projectSlug: Project['slug'];
+  attachmentIndex?: number;
+  attachments?: EventAttachment[];
+  enablePagination?: boolean;
+  groupId?: string;
+}) {
+  return render(
+    <Modal
+      Header={stubEl}
+      Footer={stubEl as ModalRenderProps['Footer']}
+      Body={stubEl as ModalRenderProps['Body']}
+      CloseButton={stubEl}
+      closeModal={() => undefined}
+      onDelete={jest.fn()}
+      onDownload={jest.fn()}
+      orgSlug={organization.slug}
+      projectSlug={projectSlug}
+      eventAttachment={eventAttachment}
+      downloadUrl=""
+      pageLinks={
+        '<http://localhost/api/0/issues/group-id/attachments/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
+        '<http://localhost/api/0/issues/group-id/attachments/?cursor=0:6:0>; rel="next"; results="true"; cursor="0:6:0"'
+      }
+      attachments={attachments}
+      attachmentIndex={attachmentIndex}
+      groupId={groupId}
+      enablePagination={enablePagination}
+    />,
+    {
+      context: routerContext,
+      organization,
+    }
+  );
+}
+
+describe('Modals -> ScreenshotModal', function () {
+  let initialData;
+  let project;
+  let getAttachmentsMock;
+  beforeEach(() => {
+    initialData = initializeOrg({
+      organization: TestStubs.Organization({features: ['mobile-screenshot-gallery']}),
+      router: {
+        params: {orgId: 'org-slug', groupId: 'group-id'},
+        location: {query: {types: 'event.screenshot'}},
+      },
+    } as Parameters<typeof initializeOrg>[0]);
+    project = TestStubs.Project();
+    ProjectsStore.loadInitialData([project]);
+    GroupStore.init();
+
+    getAttachmentsMock = MockApiClient.addMockResponse({
+      url: '/issues/group-id/attachments/',
+      body: [TestStubs.EventAttachment()],
+      headers: {
+        link:
+          '<http://localhost/api/0/issues/group-id/attachments/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
+          '<http://localhost/api/0/issues/group-id/attachments/?cursor=0:6:0>; rel="next"; results="true"; cursor="0:10:0"',
+      },
+    });
+  });
+
+  afterEach(() => {
+    MockApiClient.clearMockResponses();
+  });
+  it('paginates single screenshots correctly', function () {
+    const eventAttachment = TestStubs.EventAttachment();
+    renderModal({
+      eventAttachment,
+      initialData,
+      projectSlug: project.slug,
+      attachmentIndex: 0,
+      attachments: [
+        eventAttachment,
+        TestStubs.EventAttachment({id: '2', event_id: 'new event id'}),
+        TestStubs.EventAttachment({id: '3'}),
+        TestStubs.EventAttachment({id: '4'}),
+        TestStubs.EventAttachment({id: '5'}),
+        TestStubs.EventAttachment({id: '6'}),
+      ],
+      enablePagination: true,
+      groupId: 'group-id',
+    });
+    expect(screen.getByRole('button', {name: 'Previous'})).toBeDisabled();
+    userEvent.click(screen.getByRole('button', {name: 'Next'}));
+
+    expect(screen.getByText('new event id')).toBeInTheDocument();
+    expect(screen.getByRole('button', {name: 'Previous'})).toBeEnabled();
+  });
+
+  it('fetches a new batch of screenshots correctly', async function () {
+    const eventAttachment = TestStubs.EventAttachment();
+    renderModal({
+      eventAttachment,
+      initialData,
+      projectSlug: project.slug,
+      attachmentIndex: 5,
+      attachments: [
+        TestStubs.EventAttachment({id: '2'}),
+        TestStubs.EventAttachment({id: '3'}),
+        TestStubs.EventAttachment({id: '4'}),
+        TestStubs.EventAttachment({id: '5'}),
+        TestStubs.EventAttachment({id: '6'}),
+        eventAttachment,
+      ],
+      enablePagination: true,
+      groupId: 'group-id',
+    });
+    userEvent.click(screen.getByRole('button', {name: 'Next'}));
+
+    await waitFor(() => {
+      expect(getAttachmentsMock).toHaveBeenCalledWith(
+        '/issues/group-id/attachments/',
+        expect.objectContaining({
+          query: expect.objectContaining({cursor: '0:6:0'}),
+        })
+      );
+    });
+  });
+});

+ 99 - 6
static/app/components/events/eventTagsAndScreenshot/screenshot/modal.tsx

@@ -1,4 +1,4 @@
-import {Fragment} from 'react';
+import {Fragment, useState} from 'react';
 import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 
@@ -8,15 +8,21 @@ import Buttonbar from 'sentry/components/buttonBar';
 import Confirm from 'sentry/components/confirm';
 import DateTime from 'sentry/components/dateTime';
 import {getRelativeTimeFromEventDateCreated} from 'sentry/components/events/contexts/utils';
+import Link from 'sentry/components/links/link';
 import NotAvailable from 'sentry/components/notAvailable';
+import {CursorHandler} from 'sentry/components/pagination';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import {EventAttachment, Organization, Project} from 'sentry/types';
+import {EventAttachment, IssueAttachment, Organization, Project} from 'sentry/types';
 import {Event} from 'sentry/types/event';
 import {defined, formatBytesBase2} from 'sentry/utils';
 import getDynamicText from 'sentry/utils/getDynamicText';
+import parseLinkHeader from 'sentry/utils/parseLinkHeader';
+import useApi from 'sentry/utils/useApi';
+import {MAX_SCREENSHOTS_PER_PAGE} from 'sentry/views/organizationGroupDetails/groupEventAttachments/groupEventAttachments';
 
 import ImageVisualization from './imageVisualization';
+import ScreenshotPagination from './screenshotPagination';
 
 type Props = ModalRenderProps & {
   downloadUrl: string;
@@ -25,7 +31,12 @@ type Props = ModalRenderProps & {
   onDownload: () => void;
   orgSlug: Organization['slug'];
   projectSlug: Project['slug'];
+  attachmentIndex?: number;
+  attachments?: EventAttachment[];
+  enablePagination?: boolean;
   event?: Event;
+  groupId?: string;
+  pageLinks?: string | null | undefined;
 };
 
 function Modal({
@@ -39,13 +50,82 @@ function Modal({
   onDelete,
   downloadUrl,
   onDownload,
+  pageLinks: initialPageLinks,
+  attachmentIndex,
+  attachments,
+  enablePagination,
+  groupId,
 }: Props) {
-  const {dateCreated, size, mimetype} = eventAttachment;
+  const api = useApi();
+
+  const [currentEventAttachment, setCurrentAttachment] =
+    useState<EventAttachment>(eventAttachment);
+  const [currentAttachmentIndex, setCurrentAttachmentIndex] = useState<
+    number | undefined
+  >(attachmentIndex);
+  const [memoizedAttachments, setMemoizedAttachments] = useState<
+    IssueAttachment[] | undefined
+  >(attachments);
+
+  const [pageLinks, setPageLinks] = useState<string | null | undefined>(initialPageLinks);
+
+  const handleCursor: CursorHandler = (cursor, _pathname, query, delta) => {
+    if (defined(currentAttachmentIndex) && memoizedAttachments?.length) {
+      const newAttachmentIndex = currentAttachmentIndex + delta;
+      if (newAttachmentIndex === MAX_SCREENSHOTS_PER_PAGE || newAttachmentIndex === -1) {
+        api
+          .requestPromise(`/issues/${groupId}/attachments/`, {
+            method: 'GET',
+            includeAllArgs: true,
+            query: {
+              ...query,
+              per_page: MAX_SCREENSHOTS_PER_PAGE,
+              types: undefined,
+              screenshot: 1,
+              cursor,
+            },
+          })
+          .then(([data, _, resp]) => {
+            if (newAttachmentIndex === MAX_SCREENSHOTS_PER_PAGE) {
+              setCurrentAttachmentIndex(0);
+              setCurrentAttachment(data[0]);
+            } else {
+              setCurrentAttachmentIndex(MAX_SCREENSHOTS_PER_PAGE - 1);
+              setCurrentAttachment(data[MAX_SCREENSHOTS_PER_PAGE - 1]);
+            }
+            setMemoizedAttachments(data);
+            setPageLinks(resp?.getResponseHeader('Link'));
+          });
+        return;
+      }
+      setCurrentAttachmentIndex(newAttachmentIndex);
+      setCurrentAttachment(memoizedAttachments[newAttachmentIndex]);
+    }
+  };
+
+  const {dateCreated, size, mimetype} = currentEventAttachment;
+  const links = pageLinks ? parseLinkHeader(pageLinks) : undefined;
+  const previousDisabled =
+    links?.previous?.results === false && currentAttachmentIndex === 0;
+  const nextDisabled = links?.next?.results === false && currentAttachmentIndex === 5;
+
   return (
     <Fragment>
       <Header closeButton>{t('Screenshot')}</Header>
       <Body>
         <GeralInfo>
+          {groupId && enablePagination && (
+            <Fragment>
+              <Label>{t('Event ID')}</Label>
+              <Value>
+                <Title
+                  to={`/organizations/${orgSlug}/issues/${groupId}/events/${currentEventAttachment.event_id}/`}
+                >
+                  {currentEventAttachment.event_id}
+                </Title>
+              </Value>
+            </Fragment>
+          )}
           <Label coloredBg>{t('Date Created')}</Label>
           <Value coloredBg>
             {dateCreated ? (
@@ -76,10 +156,10 @@ function Modal({
         </GeralInfo>
 
         <StyledImageVisualization
-          attachment={eventAttachment}
+          attachment={currentEventAttachment}
           orgId={orgSlug}
           projectId={projectSlug}
-          eventId={eventAttachment.event_id}
+          eventId={currentEventAttachment.event_id}
         />
       </Body>
       <Footer>
@@ -98,6 +178,14 @@ function Modal({
           <Button onClick={onDownload} href={downloadUrl}>
             {t('Download')}
           </Button>
+          {enablePagination && (
+            <ScreenshotPagination
+              onCursor={handleCursor}
+              pageLinks={pageLinks}
+              previousDisabled={previousDisabled}
+              nextDisabled={nextDisabled}
+            />
+          )}
         </Buttonbar>
       </Footer>
     </Fragment>
@@ -134,8 +222,13 @@ const StyledImageVisualization = styled(ImageVisualization)`
   }
 `;
 
+const Title = styled(Link)`
+  ${p => p.theme.overflowEllipsis};
+  font-weight: normal;
+`;
+
 export const modalCss = css`
   width: auto;
   height: 100%;
-  max-width: 100%;
+  max-width: 700px;
 `;

+ 72 - 0
static/app/components/events/eventTagsAndScreenshot/screenshot/screenshotPagination.tsx

@@ -0,0 +1,72 @@
+// eslint-disable-next-line no-restricted-imports
+import {withRouter, WithRouterProps} from 'react-router';
+import styled from '@emotion/styled';
+
+import Button from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import {CursorHandler} from 'sentry/components/pagination';
+import {IconChevron} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import parseLinkHeader from 'sentry/utils/parseLinkHeader';
+
+type Props = WithRouterProps & {
+  nextDisabled: boolean;
+  onCursor: CursorHandler;
+  previousDisabled: boolean;
+  pageLinks?: string | null;
+};
+
+/*
+  HACK: Slight variation of the Pagination
+  component that allows the parent to control
+  enabling/disabling the pagination buttons.
+*/
+const ScreenshotPagination = ({
+  location,
+  onCursor,
+  pageLinks,
+  previousDisabled,
+  nextDisabled,
+}: Props) => {
+  if (!pageLinks) {
+    return null;
+  }
+
+  const path = location.pathname;
+  const query = location.query;
+  const links = parseLinkHeader(pageLinks);
+
+  return (
+    <Wrapper>
+      <ButtonBar merged>
+        <Button
+          icon={<IconChevron direction="left" size="sm" />}
+          aria-label={t('Previous')}
+          size="md"
+          disabled={previousDisabled}
+          onClick={() => {
+            onCursor?.(links.previous?.cursor, path, query, -1);
+          }}
+        />
+        <Button
+          icon={<IconChevron direction="right" size="sm" />}
+          aria-label={t('Next')}
+          size="md"
+          disabled={nextDisabled}
+          onClick={() => {
+            onCursor?.(links.next?.cursor, path, query, 1);
+          }}
+        />
+      </ButtonBar>
+    </Wrapper>
+  );
+};
+
+const Wrapper = styled('div')`
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  margin: 0;
+`;
+
+export default withRouter(ScreenshotPagination);

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

@@ -39,6 +39,8 @@ type State = {
   eventAttachments?: IssueAttachment[];
 } & AsyncComponent['state'];
 
+export const MAX_SCREENSHOTS_PER_PAGE = 6;
+
 class GroupEventAttachments extends AsyncComponent<Props, State> {
   getDefaultState() {
     return {
@@ -76,7 +78,7 @@ class GroupEventAttachments extends AsyncComponent<Props, State> {
               ...location.query,
               types: undefined, // need to explicitly set this to undefined because AsyncComponent adds location query back into the params
               screenshot: 1,
-              per_page: 6,
+              per_page: MAX_SCREENSHOTS_PER_PAGE,
             },
           },
         ],
@@ -197,6 +199,9 @@ class GroupEventAttachments extends AsyncComponent<Props, State> {
                 projectSlug={projectSlug}
                 groupId={params.groupId}
                 onDelete={this.handleDelete}
+                pageLinks={this.state.eventAttachmentsPageLinks}
+                attachments={eventAttachments}
+                attachmentIndex={index}
               />
             );
           })}

+ 11 - 0
static/app/views/organizationGroupDetails/groupEventAttachments/screenshotCard.tsx

@@ -23,11 +23,14 @@ import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAna
 import useOrganization from 'sentry/utils/useOrganization';
 
 type Props = {
+  attachmentIndex: number;
+  attachments: IssueAttachment[];
   eventAttachment: IssueAttachment;
   eventId: string;
   groupId: string;
   onDelete: (attachmentId: string) => void;
   projectSlug: Project['slug'];
+  pageLinks?: string | null | undefined;
 };
 
 export function ScreenshotCard({
@@ -36,6 +39,9 @@ export function ScreenshotCard({
   eventId,
   groupId,
   onDelete,
+  pageLinks,
+  attachmentIndex,
+  attachments,
 }: Props) {
   const organization = useOrganization();
   const [loadingImage, setLoadingImage] = useState(true);
@@ -62,6 +68,11 @@ export function ScreenshotCard({
           eventAttachment={eventAttachment}
           downloadUrl={downloadUrl}
           onDelete={handleDelete}
+          pageLinks={pageLinks}
+          attachments={attachments}
+          attachmentIndex={attachmentIndex}
+          groupId={groupId}
+          enablePagination
           onDownload={() =>
             trackAdvancedAnalyticsEvent(
               'issue_details.attachment_tab.screenshot_modal_download',