Browse Source

feat(mobile-screenshots): Add visualization modal (#27350)

Priscila Oliveira 3 years ago
parent
commit
6d38a7c3ed

+ 2 - 0
static/app/components/actions/actionLink.tsx

@@ -49,6 +49,7 @@ export default function ActionLink({
   children,
   shouldConfirm,
   confirmPriority,
+  header,
   ...props
 }: Props) {
   const actionCommonProps = {
@@ -74,6 +75,7 @@ export default function ActionLink({
         priority={confirmPriority}
         disabled={disabled}
         message={message}
+        header={header}
         confirmText={confirmLabel}
         onConfirm={onAction}
         stopPropagation={disabled}

+ 3 - 1
static/app/components/actions/confirmableAction.tsx

@@ -6,7 +6,9 @@ type ConfirmProps = React.ComponentProps<typeof Confirm>;
 type Props = {
   children: React.ReactNode | ConfirmProps['children'];
   shouldConfirm?: boolean;
-} & Partial<Pick<ConfirmProps, 'confirmText' | 'priority' | 'stopPropagation'>> &
+} & Partial<
+  Pick<ConfirmProps, 'confirmText' | 'priority' | 'stopPropagation' | 'header'>
+> &
   Pick<ConfirmProps, 'message' | 'disabled' | 'confirmText' | 'onConfirm'>;
 
 export default function ConfirmableAction({shouldConfirm, children, ...props}: Props) {

+ 6 - 1
static/app/components/events/contexts/utils.tsx

@@ -40,7 +40,8 @@ export function getSourcePlugin(pluginContexts: Array<any>, contextType: string)
 
 export function getRelativeTimeFromEventDateCreated(
   eventDateCreated: string,
-  timestamp?: string
+  timestamp?: string,
+  showTimestamp = true
 ) {
   if (!defined(timestamp)) {
     return timestamp;
@@ -56,6 +57,10 @@ export function getRelativeTimeFromEventDateCreated(
     'before this event'
   )})`;
 
+  if (!showTimestamp) {
+    return <RelativeTime>{relativeTime}</RelativeTime>;
+  }
+
   return (
     <Fragment>
       {timestamp}

+ 0 - 1
static/app/components/events/eventTagsAndScreenshot/dataSection.tsx

@@ -51,7 +51,6 @@ const Title = styled('h3')`
 const StyledEventDataSection = styled(EventDataSection)`
   ${SectionContents} {
     flex: 1;
-    overflow: hidden;
   }
 
   @media (min-width: ${p => p.theme.breakpoints[0]}) {

+ 0 - 64
static/app/components/events/eventTagsAndScreenshot/screenshot/emptyState.tsx

@@ -1,64 +0,0 @@
-import styled from '@emotion/styled';
-
-import emptyStateImg from 'sentry-images/spot/feedback-empty-state.svg';
-
-import Button, {ButtonLabel} from 'app/components/button';
-import ButtonBar from 'app/components/buttonBar';
-import {t} from 'app/locale';
-import space from 'app/styles/space';
-import {PlatformType} from 'app/types';
-
-import {getConfigureAttachmentsDocsLink} from './utils';
-
-type Props = {
-  platform?: PlatformType;
-};
-
-function EmptyState({platform}: Props) {
-  const configureAttachmentsDocsLink = getConfigureAttachmentsDocsLink(platform);
-
-  return (
-    <Wrapper>
-      <img src={emptyStateImg} />
-      <StyledButtonbar gap={1}>
-        <Button priority="link" size="xsmall" to={configureAttachmentsDocsLink} external>
-          {t('Setup screenshot')}
-        </Button>
-        {'|'}
-        <Button priority="link" size="xsmall">
-          {t('Dismiss')}
-        </Button>
-      </StyledButtonbar>
-    </Wrapper>
-  );
-}
-
-export default EmptyState;
-
-const Wrapper = styled('div')`
-  width: 100%;
-  overflow: hidden;
-  padding: ${space(2)} ${space(2)} ${space(1)} ${space(2)};
-  display: flex;
-  flex-direction: column;
-  &,
-  img {
-    flex: 1;
-  }
-  img {
-    height: 100%;
-    width: auto;
-    overflow: hidden;
-    max-height: 100%;
-  }
-`;
-
-const StyledButtonbar = styled(ButtonBar)`
-  color: ${p => p.theme.gray200};
-  justify-content: flex-start;
-  margin-top: ${space(2)};
-  ${ButtonLabel} {
-    font-size: ${p => p.theme.fontSizeMedium};
-    white-space: nowrap;
-  }
-`;

+ 16 - 0
static/app/components/events/eventTagsAndScreenshot/screenshot/imageVisualization.tsx

@@ -0,0 +1,16 @@
+import styled from '@emotion/styled';
+
+import ImageViewer from 'app/components/events/attachmentViewers/imageViewer';
+
+const ImageVisualization = styled(ImageViewer)`
+  padding: 0;
+  height: 100%;
+  img {
+    width: auto;
+    height: 100%;
+    object-fit: cover;
+    flex: 1;
+  }
+`;
+
+export default ImageVisualization;

+ 77 - 54
static/app/components/events/eventTagsAndScreenshot/screenshot/index.tsx

@@ -1,16 +1,15 @@
 import {Fragment, useEffect, useState} from 'react';
 import styled from '@emotion/styled';
 
+import {openModal} from 'app/actionCreators/modal';
 import {Client} from 'app/api';
 import Role from 'app/components/acl/role';
 import MenuItemActionLink from 'app/components/actions/menuItemActionLink';
 import Button from 'app/components/button';
 import ButtonBar from 'app/components/buttonBar';
 import DropdownLink from 'app/components/dropdownLink';
-import ImageViewer from 'app/components/events/attachmentViewers/imageViewer';
-import LoadingIndicator from 'app/components/loadingIndicator';
 import {Panel, PanelBody, PanelFooter} from 'app/components/panels';
-import {IconDownload, IconEllipsis} from 'app/icons';
+import {IconEllipsis} from 'app/icons';
 import {t} from 'app/locale';
 import space from 'app/styles/space';
 import {EventAttachment, Organization, Project} from 'app/types';
@@ -19,8 +18,8 @@ import withApi from 'app/utils/withApi';
 
 import DataSection from '../dataSection';
 
-import EmptyState from './emptyState';
-import {platformsMobileWithAttachmentsFeature} from './utils';
+import ImageVisualization from './imageVisualization';
+import Modal, {modalCss} from './modal';
 
 type Props = {
   event: Event;
@@ -33,7 +32,6 @@ function Screenshot({event, api, organization, projectSlug}: Props) {
   const [attachments, setAttachments] = useState<EventAttachment[]>([]);
   const [isLoading, setIsLoading] = useState(false);
   const orgSlug = organization.slug;
-  const eventPlatform = event.platform;
 
   useEffect(() => {
     fetchData();
@@ -59,35 +57,53 @@ function Screenshot({event, api, organization, projectSlug}: Props) {
     }
   }
 
-  function hasPreview(attachment: EventAttachment) {
-    switch (attachment.mimetype) {
-      case 'image/jpeg':
-      case 'image/png':
-      case 'image/gif':
-        return true;
-      default:
-        return false;
-    }
+  function hasScreenshot(attachment: EventAttachment) {
+    const {mimetype} = attachment;
+    return mimetype === 'image/jpeg' || mimetype === 'image/png';
   }
 
-  function renderContent() {
-    if (isLoading) {
-      return <LoadingIndicator mini />;
-    }
-
-    const firstAttachmenteWithPreview = attachments.find(hasPreview);
+  async function handleDelete(screenshotAttachmentId: string, downloadUrl: string) {
+    try {
+      await api.requestPromise(downloadUrl.split('/api/0')[1], {
+        method: 'DELETE',
+      });
 
-    if (!firstAttachmenteWithPreview) {
-      return <EmptyState platform={eventPlatform} />;
+      setAttachments(
+        attachments.filter(attachment => attachment.id !== screenshotAttachmentId)
+      );
+    } catch (_err) {
+      // TODO: Error-handling
     }
+  }
 
-    const downloadUrl = `/api/0/projects/${organization.slug}/${projectSlug}/events/${event.id}/attachments/${firstAttachmenteWithPreview.id}/`;
+  function handleOpenVisualizationModal(
+    eventAttachment: EventAttachment,
+    downloadUrl: string
+  ) {
+    openModal(
+      modalProps => (
+        <Modal
+          {...modalProps}
+          event={event}
+          orgSlug={orgSlug}
+          projectSlug={projectSlug}
+          eventAttachment={eventAttachment}
+          downloadUrl={downloadUrl}
+          onDelete={() => handleDelete(eventAttachment.id, downloadUrl)}
+        />
+      ),
+      {modalCss}
+    );
+  }
+
+  function renderContent(screenshotAttachment: EventAttachment) {
+    const downloadUrl = `/api/0/projects/${organization.slug}/${projectSlug}/events/${event.id}/attachments/${screenshotAttachment.id}/`;
 
     return (
       <Fragment>
         <StyledPanelBody>
-          <StyledImageViewer
-            attachment={firstAttachmenteWithPreview}
+          <ImageVisualization
+            attachment={screenshotAttachment}
             orgId={orgSlug}
             projectId={projectSlug}
             event={event}
@@ -95,7 +111,17 @@ function Screenshot({event, api, organization, projectSlug}: Props) {
         </StyledPanelBody>
         <StyledPanelFooter>
           <StyledButtonbar gap={1}>
-            <Button size="xsmall">{t('View screenshot')}</Button>
+            <Button
+              size="xsmall"
+              onClick={() =>
+                handleOpenVisualizationModal(
+                  screenshotAttachment,
+                  `${downloadUrl}?download=1`
+                )
+              }
+            >
+              {t('View screenshot')}
+            </Button>
             <DropdownLink
               caret={false}
               customTitle={
@@ -109,12 +135,22 @@ function Screenshot({event, api, organization, projectSlug}: Props) {
             >
               <MenuItemActionLink
                 shouldConfirm={false}
-                icon={<IconDownload size="xs" />}
                 title={t('Download')}
                 href={`${downloadUrl}?download=1`}
               >
                 {t('Download')}
               </MenuItemActionLink>
+              <MenuItemActionLink
+                shouldConfirm
+                title={t('Delete')}
+                onAction={() => handleDelete(screenshotAttachment.id, downloadUrl)}
+                header={t(
+                  'Screenshots help identify what the user saw when the event happened'
+                )}
+                message={t('Are you sure you wish to delete this screenshot?')}
+              >
+                {t('Delete')}
+              </MenuItemActionLink>
             </DropdownLink>
           </StyledButtonbar>
         </StyledPanelFooter>
@@ -122,30 +158,23 @@ function Screenshot({event, api, organization, projectSlug}: Props) {
     );
   }
 
-  // the UI should only render the screenshots feature in events with platforms that support screenshots
-  if (
-    !eventPlatform ||
-    !platformsMobileWithAttachmentsFeature.includes(eventPlatform as any)
-  ) {
-    return null;
-  }
-
   return (
     <Role role={organization.attachmentsRole}>
       {({hasRole}) => {
-        if (!hasRole) {
-          // if the user has no access to the attachments,
-          // the UI shall not display the screenshot section
+        const screenshotAttachment = attachments.find(hasScreenshot);
+
+        if (!hasRole || isLoading || !screenshotAttachment) {
           return null;
         }
+
         return (
           <DataSection
             title={t('Screenshots')}
             description={t(
-              'Screenshots help identify what the user saw when the exception happened'
+              'Screenshots help identify what the user saw when the event happened'
             )}
           >
-            <StyledPanel>{renderContent()}</StyledPanel>
+            <StyledPanel>{renderContent(screenshotAttachment)}</StyledPanel>
           </DataSection>
         );
       }}
@@ -161,14 +190,19 @@ const StyledPanel = styled(Panel)`
   justify-content: center;
   align-items: center;
   margin-bottom: 0;
-  min-width: 175px;
   min-height: 200px;
+  min-width: 175px;
 `;
 
 const StyledPanelBody = styled(PanelBody)`
   height: 175px;
-  width: 100%;
   overflow: hidden;
+  border: 1px solid ${p => p.theme.border};
+  border-radius: ${p => p.theme.borderRadius};
+  margin: -1px;
+  width: calc(100% + 2px);
+  border-bottom-left-radius: 0;
+  border-bottom-right-radius: 0;
 `;
 
 const StyledPanelFooter = styled(PanelFooter)`
@@ -182,14 +216,3 @@ const StyledButtonbar = styled(ButtonBar)`
     height: 24px;
   }
 `;
-
-const StyledImageViewer = styled(ImageViewer)`
-  padding: 0;
-  height: 100%;
-  img {
-    width: auto;
-    height: 100%;
-    object-fit: cover;
-    flex: 1;
-  }
-`;

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

@@ -0,0 +1,161 @@
+import {Fragment} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {ModalRenderProps} from 'app/actionCreators/modal';
+import Button from 'app/components/button';
+import Buttonbar from 'app/components/buttonBar';
+import Confirm from 'app/components/confirm';
+import DateTime from 'app/components/dateTime';
+import {getRelativeTimeFromEventDateCreated} from 'app/components/events/contexts/utils';
+import NotAvailable from 'app/components/notAvailable';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+import {EventAttachment, Organization, Project} from 'app/types';
+import {Event} from 'app/types/event';
+import getDynamicText from 'app/utils/getDynamicText';
+
+import ImageVisualization from './imageVisualization';
+
+type Props = ModalRenderProps & {
+  eventAttachment: EventAttachment;
+  orgSlug: Organization['slug'];
+  projectSlug: Project['slug'];
+  event: Event;
+  onDelete: () => void;
+  downloadUrl: string;
+};
+
+function Modal({
+  eventAttachment,
+  orgSlug,
+  projectSlug,
+  Header,
+  Body,
+  Footer,
+  event,
+  onDelete,
+  downloadUrl,
+}: Props) {
+  const {dateCreated, name, size, mimetype, type} = eventAttachment;
+  return (
+    <Fragment>
+      <Header closeButton>
+        <Title>
+          {t('Screenshot')}
+          <FileName>
+            {name ? name.split(`.${name.split('.').pop()}`)[0] : t('Unknown')}
+          </FileName>
+        </Title>
+      </Header>
+      <Body>
+        <GeralInfo>
+          <Label coloredBg>{t('Date Created')}</Label>
+          <Value coloredBg>
+            {dateCreated ? (
+              <Fragment>
+                <DateTime
+                  date={getDynamicText({
+                    value: dateCreated,
+                    fixed: new Date(1508208080000),
+                  })}
+                />
+                {getRelativeTimeFromEventDateCreated(
+                  event.dateCreated,
+                  dateCreated,
+                  false
+                )}
+              </Fragment>
+            ) : (
+              <NotAvailable />
+            )}
+          </Value>
+
+          <Label>{t('Name')}</Label>
+          <Value>{name ?? <NotAvailable />}</Value>
+
+          <Label coloredBg>{t('Size')}</Label>
+          <Value coloredBg>{size ?? <NotAvailable />}</Value>
+
+          <Label>{t('Mimetype')}</Label>
+          <Value>{mimetype ?? <NotAvailable />}</Value>
+
+          <Label coloredBg>{t('Type')}</Label>
+          <Value coloredBg>{type ?? <NotAvailable />}</Value>
+        </GeralInfo>
+
+        <StyledImageVisualization
+          attachment={eventAttachment}
+          orgId={orgSlug}
+          projectId={projectSlug}
+          event={event}
+        />
+      </Body>
+      <Footer>
+        <Buttonbar gap={1}>
+          <Confirm
+            confirmText={t('Delete')}
+            header={t(
+              'Screenshots help identify what the user saw when the event happened'
+            )}
+            message={t('Are you sure you wish to delete this screenshot?')}
+            priority="danger"
+            onConfirm={onDelete}
+          >
+            <Button priority="danger">{t('Delete')}</Button>
+          </Confirm>
+          <Button href={downloadUrl}>{t('Download')}</Button>
+        </Buttonbar>
+      </Footer>
+    </Fragment>
+  );
+}
+
+export default Modal;
+
+const Title = styled('div')`
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  grid-gap: ${space(1)};
+  align-items: center;
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+  max-width: calc(100% - 40px);
+  word-break: break-all;
+`;
+
+const FileName = styled('span')`
+  font-family: ${p => p.theme.text.familyMono};
+`;
+
+const GeralInfo = styled('div')`
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  margin-bottom: ${space(3)};
+`;
+
+const Label = styled('div')<{coloredBg?: boolean}>`
+  color: ${p => p.theme.textColor};
+  padding: ${space(1)} ${space(1.5)} ${space(1)} ${space(1)};
+  ${p => p.coloredBg && `background-color: ${p.theme.backgroundSecondary};`}
+`;
+
+const Value = styled(Label)`
+  white-space: pre-wrap;
+  word-break: break-all;
+  color: ${p => p.theme.subText};
+  padding: ${space(1)};
+  font-family: ${p => p.theme.text.familyMono};
+  ${p => p.coloredBg && `background-color: ${p.theme.backgroundSecondary};`}
+`;
+
+const StyledImageVisualization = styled(ImageVisualization)`
+  img {
+    border-radius: ${p => p.theme.borderRadius};
+  }
+`;
+
+export const modalCss = css`
+  width: auto;
+  height: 100%;
+  max-width: 100%;
+`;

+ 0 - 27
static/app/components/events/eventTagsAndScreenshot/screenshot/utils.tsx

@@ -1,27 +0,0 @@
-import {PlatformType} from 'app/types';
-
-export const platformsMobileWithAttachmentsFeature = ['android', 'apple'] as const;
-
-const platformsWithAttachmentsFeature = [
-  'dotnet',
-  'javascript',
-  'native',
-  ...platformsMobileWithAttachmentsFeature,
-] as const;
-
-type DocPlatform = typeof platformsWithAttachmentsFeature[number];
-
-function validDocPlatform(platform: any): platform is DocPlatform {
-  if (!platform) {
-    return false;
-  }
-  return platformsWithAttachmentsFeature.includes(platform);
-}
-
-export function getConfigureAttachmentsDocsLink(platform?: PlatformType) {
-  if (!platform || !validDocPlatform(platform)) {
-    return undefined;
-  }
-
-  return `https://docs.sentry.io/platforms/${platform}/enriching-events/attachments/`;
-}

+ 10 - 2
static/app/components/events/eventTagsAndScreenshot/tags.tsx

@@ -1,5 +1,7 @@
+import styled from '@emotion/styled';
 import {Location} from 'history';
 
+import {SectionContents} from 'app/components/events/eventDataSection';
 import {t} from 'app/locale';
 import {Organization, Project} from 'app/types';
 import {Event} from 'app/types/event';
@@ -27,7 +29,7 @@ function Tags({
   hasQueryFeature,
 }: Props) {
   return (
-    <DataSection
+    <StyledDataSection
       title={t('Tags')}
       description={t(
         'Tags help you quickly both access related events and view the tag distribution for a set of events'
@@ -41,8 +43,14 @@ function Tags({
         location={location}
         hasQueryFeature={hasQueryFeature}
       />
-    </DataSection>
+    </StyledDataSection>
   );
 }
 
 export default Tags;
+
+const StyledDataSection = styled(DataSection)`
+  ${SectionContents} {
+    overflow: hidden;
+  }
+`;