Browse Source

feat(issue-details): Add screenshot section (#27274)

Priscila Oliveira 3 years ago
parent
commit
511e6dd636

+ 4 - 2
static/app/components/events/attachmentViewers/imageViewer.tsx

@@ -6,14 +6,16 @@ import {
 } from 'app/components/events/attachmentViewers/utils';
 import {PanelItem} from 'app/components/panels';
 
-export default function ImageViewer(props: ViewerProps) {
+function ImageViewer({className, ...props}: ViewerProps) {
   return (
-    <Container>
+    <Container className={className}>
       <img src={getAttachmentUrl(props, true)} />
     </Container>
   );
 }
 
+export default ImageViewer;
+
 const Container = styled(PanelItem)`
   justify-content: center;
 `;

+ 1 - 0
static/app/components/events/attachmentViewers/utils.tsx

@@ -6,6 +6,7 @@ export type ViewerProps = {
   orgId: string;
   projectId: string;
   attachment: EventAttachment;
+  className?: string;
 };
 
 export function getAttachmentUrl(props: ViewerProps, withPrefix?: boolean): string {

+ 0 - 1
static/app/components/events/eventAttachments.tsx

@@ -205,7 +205,6 @@ class EventAttachments extends React.Component<Props, State> {
                   )}
                 </AttachmentUrl>
                 {this.renderInlineAttachment(attachment)}
-
                 {/* XXX: hack to deal with table grid borders */}
                 {lastAttachmentPreviewed && (
                   <React.Fragment>

+ 2 - 2
static/app/components/events/eventDataSection.tsx

@@ -20,11 +20,11 @@ const defaultProps = {
 type DefaultProps = Readonly<typeof defaultProps>;
 
 type Props = {
-  className?: string;
   title: React.ReactNode;
   type: string;
   toggleRaw?: (enable: boolean) => void;
   actions?: React.ReactNode;
+  className?: string;
 } & DefaultProps;
 
 class EventDataSection extends React.Component<Props> {
@@ -191,7 +191,7 @@ const SectionHeader = styled('div')<{isCentered?: boolean}>`
   }
 `;
 
-const SectionContents = styled('div')`
+export const SectionContents = styled('div')`
   position: relative;
 `;
 

+ 24 - 6
static/app/components/events/eventEntries.tsx

@@ -46,6 +46,7 @@ import {projectProcessingIssuesMessages} from 'app/views/settings/project/projec
 import findBestThread from './interfaces/threads/threadSelector/findBestThread';
 import getThreadException from './interfaces/threads/threadSelector/getThreadException';
 import EventEntry from './eventEntry';
+import EventAndScreenshot from './eventTagsAndScreenshot';
 
 const MINIFIED_DATA_JAVA_EVENT_REGEX_MATCH =
   /^(([\w\$]\.[\w\$]{1,2})|([\w\$]{2}\.[\w\$]\.[\w\$]))(\.|$)/g;
@@ -54,6 +55,7 @@ const defaultProps = {
   isShare: false,
   showExampleCommit: false,
   showTagSummary: true,
+  isBorderless: false,
 };
 
 type ProGuardErrors = Array<Error>;
@@ -322,11 +324,13 @@ class EventEntries extends Component<Props, State> {
       showExampleCommit,
       showTagSummary,
       location,
+      isBorderless,
     } = this.props;
     const {proGuardErrors, isLoading} = this.state;
 
     const features = new Set(organization?.features);
     const hasQueryFeature = features.has('discover-query');
+    const hasMobileScreenshotsFeature = features.has('mobile-screenshots');
 
     if (!event) {
       return (
@@ -373,18 +377,32 @@ class EventEntries extends Component<Props, State> {
             includeBorder={!hasErrors}
           />
         )}
-        {showTagSummary && (
-          <StyledEventDataSection title={t('Tags')} type="tags">
-            {hasContext && <EventContextSummary event={event} />}
-            <EventTags
+        {showTagSummary &&
+          (hasMobileScreenshotsFeature ? (
+            <EventAndScreenshot
               event={event}
               organization={organization as Organization}
               projectId={project.slug}
               location={location}
               hasQueryFeature={hasQueryFeature}
+              isShare={isShare}
+              hasContext={hasContext}
+              isBorderless={isBorderless}
             />
-          </StyledEventDataSection>
-        )}
+          ) : (
+            (!!(event.tags ?? []).length || hasContext) && (
+              <StyledEventDataSection title={t('Tags')} type="tags">
+                {hasContext && <EventContextSummary event={event} />}
+                <EventTags
+                  event={event}
+                  organization={organization as Organization}
+                  projectId={project.slug}
+                  location={location}
+                  hasQueryFeature={hasQueryFeature}
+                />
+              </StyledEventDataSection>
+            )
+          ))}
         {this.renderEntries(event)}
         {hasContext && <EventContexts group={group} event={event} />}
         {event && !objectIsEmpty(event.context) && <EventExtraData event={event} />}

+ 2 - 3
static/app/components/events/eventTags/eventTags.tsx

@@ -1,5 +1,4 @@
 import {Location} from 'history';
-import isEmpty from 'lodash/isEmpty';
 
 import Pills from 'app/components/pills';
 import {Organization} from 'app/types';
@@ -17,13 +16,13 @@ type Props = {
 };
 
 const EventTags = ({
-  event: {tags},
+  event: {tags = []},
   organization,
   projectId,
   location,
   hasQueryFeature,
 }: Props) => {
-  if (isEmpty(tags)) {
+  if (!tags.length) {
     return null;
   }
 

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

@@ -0,0 +1,63 @@
+import styled from '@emotion/styled';
+import kebabCase from 'lodash/kebabCase';
+
+import GuideAnchor from 'app/components/assistant/guideAnchor';
+import EventDataSection, {SectionContents} from 'app/components/events/eventDataSection';
+import QuestionTooltip from 'app/components/questionTooltip';
+import space from 'app/styles/space';
+
+type Props = {
+  title: string;
+  description: string;
+  children: React.ReactNode;
+};
+
+function DataSection({title, description, children}: Props) {
+  const type = kebabCase(title);
+  return (
+    <StyledEventDataSection
+      type={type}
+      title={
+        <TitleWrapper>
+          <GuideAnchor target={type} position="bottom">
+            <Title>{title}</Title>
+          </GuideAnchor>
+          <QuestionTooltip size="xs" position="top" title={description} />
+        </TitleWrapper>
+      }
+      wrapTitle={false}
+    >
+      {children}
+    </StyledEventDataSection>
+  );
+}
+
+export default DataSection;
+
+const TitleWrapper = styled('div')`
+  display: grid;
+  grid-template-columns: repeat(2, max-content);
+  grid-gap: ${space(0.5)};
+  align-items: center;
+  padding: ${space(0.75)} 0;
+`;
+
+const Title = styled('h3')`
+  margin-bottom: 0;
+  padding: 0 !important;
+  height: 14px;
+`;
+
+const StyledEventDataSection = styled(EventDataSection)`
+  ${SectionContents} {
+    flex: 1;
+    overflow: hidden;
+  }
+
+  @media (min-width: ${p => p.theme.breakpoints[0]}) {
+    && {
+      padding: 0;
+      border: 0;
+    }
+  }
+`;

+ 82 - 0
static/app/components/events/eventTagsAndScreenshot/index.tsx

@@ -0,0 +1,82 @@
+import styled from '@emotion/styled';
+
+import {DataSection} from 'app/components/events/styles';
+import space from 'app/styles/space';
+
+import Screenshot from './screenshot';
+import Tags from './tags';
+
+type Props = Omit<React.ComponentProps<typeof Tags>, 'projectSlug'> & {
+  projectId: string;
+  isShare: boolean;
+  hasContext: boolean;
+  isBorderless: boolean;
+};
+
+function EventTagsAndScreenshots({
+  projectId: projectSlug,
+  isShare,
+  hasContext,
+  hasQueryFeature,
+  location,
+  isBorderless,
+  event,
+  ...props
+}: Props) {
+  const {tags = []} = event;
+
+  if (!tags.length && !hasContext && isShare) {
+    return null;
+  }
+
+  return (
+    <Wrapper isBorderless={isBorderless}>
+      {!isShare && <Screenshot {...props} event={event} projectSlug={projectSlug} />}
+      <Tags
+        {...props}
+        event={event}
+        projectSlug={projectSlug}
+        hasContext={hasContext}
+        hasQueryFeature={hasQueryFeature}
+        location={location}
+      />
+    </Wrapper>
+  );
+}
+
+export default EventTagsAndScreenshots;
+
+const Wrapper = styled(DataSection)<{isBorderless: boolean}>`
+  display: grid;
+  grid-gap: ${space(3)};
+
+  @media (max-width: ${p => p.theme.breakpoints[0]}) {
+    && {
+      padding: 0;
+      border: 0;
+    }
+  }
+
+  @media (min-width: ${p => p.theme.breakpoints[0]}) {
+    padding-bottom: ${space(2)};
+    grid-template-columns: auto minmax(0, 1fr);
+    grid-gap: ${space(4)};
+
+    > *:first-child {
+      border-bottom: 0;
+      padding-bottom: 0;
+    }
+  }
+
+  ${p =>
+    p.isBorderless &&
+    `
+    && {
+        padding: ${space(3)} 0 0 0;
+        :first-child {
+          padding-top: 0;
+          border-top: 0;
+        }
+      }
+    `}
+`;

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

@@ -0,0 +1,64 @@
+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;
+  }
+`;

+ 195 - 0
static/app/components/events/eventTagsAndScreenshot/screenshot/index.tsx

@@ -0,0 +1,195 @@
+import {Fragment, useEffect, useState} from 'react';
+import styled from '@emotion/styled';
+
+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 {t} from 'app/locale';
+import space from 'app/styles/space';
+import {EventAttachment, Organization, Project} from 'app/types';
+import {Event} from 'app/types/event';
+import withApi from 'app/utils/withApi';
+
+import DataSection from '../dataSection';
+
+import EmptyState from './emptyState';
+import {platformsMobileWithAttachmentsFeature} from './utils';
+
+type Props = {
+  event: Event;
+  api: Client;
+  organization: Organization;
+  projectSlug: Project['slug'];
+};
+
+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();
+  }, []);
+
+  async function fetchData() {
+    if (!event) {
+      return;
+    }
+
+    setIsLoading(true);
+
+    try {
+      const response = await api.requestPromise(
+        `/projects/${orgSlug}/${projectSlug}/events/${event.id}/attachments/`
+      );
+      setAttachments(response);
+      setIsLoading(false);
+    } catch (_err) {
+      // TODO: Error-handling
+      setAttachments([]);
+      setIsLoading(false);
+    }
+  }
+
+  function hasPreview(attachment: EventAttachment) {
+    switch (attachment.mimetype) {
+      case 'image/jpeg':
+      case 'image/png':
+      case 'image/gif':
+        return true;
+      default:
+        return false;
+    }
+  }
+
+  function renderContent() {
+    if (isLoading) {
+      return <LoadingIndicator mini />;
+    }
+
+    const firstAttachmenteWithPreview = attachments.find(hasPreview);
+
+    if (!firstAttachmenteWithPreview) {
+      return <EmptyState platform={eventPlatform} />;
+    }
+
+    const downloadUrl = `/api/0/projects/${organization.slug}/${projectSlug}/events/${event.id}/attachments/${firstAttachmenteWithPreview.id}/`;
+
+    return (
+      <Fragment>
+        <StyledPanelBody>
+          <StyledImageViewer
+            attachment={firstAttachmenteWithPreview}
+            orgId={orgSlug}
+            projectId={projectSlug}
+            event={event}
+          />
+        </StyledPanelBody>
+        <StyledPanelFooter>
+          <StyledButtonbar gap={1}>
+            <Button size="xsmall">{t('View screenshot')}</Button>
+            <DropdownLink
+              caret={false}
+              customTitle={
+                <Button
+                  label={t('Actions')}
+                  size="xsmall"
+                  icon={<IconEllipsis size="xs" />}
+                />
+              }
+              anchorRight
+            >
+              <MenuItemActionLink
+                shouldConfirm={false}
+                icon={<IconDownload size="xs" />}
+                title={t('Download')}
+                href={`${downloadUrl}?download=1`}
+              >
+                {t('Download')}
+              </MenuItemActionLink>
+            </DropdownLink>
+          </StyledButtonbar>
+        </StyledPanelFooter>
+      </Fragment>
+    );
+  }
+
+  // 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
+          return null;
+        }
+        return (
+          <DataSection
+            title={t('Screenshots')}
+            description={t(
+              'Screenshots help identify what the user saw when the exception happened'
+            )}
+          >
+            <StyledPanel>{renderContent()}</StyledPanel>
+          </DataSection>
+        );
+      }}
+    </Role>
+  );
+}
+
+export default withApi(Screenshot);
+
+const StyledPanel = styled(Panel)`
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  margin-bottom: 0;
+  min-width: 175px;
+  min-height: 200px;
+`;
+
+const StyledPanelBody = styled(PanelBody)`
+  height: 175px;
+  width: 100%;
+  overflow: hidden;
+`;
+
+const StyledPanelFooter = styled(PanelFooter)`
+  padding: ${space(1)};
+  width: 100%;
+`;
+
+const StyledButtonbar = styled(ButtonBar)`
+  justify-content: space-between;
+  .dropdown {
+    height: 24px;
+  }
+`;
+
+const StyledImageViewer = styled(ImageViewer)`
+  padding: 0;
+  height: 100%;
+  img {
+    width: auto;
+    height: 100%;
+    object-fit: cover;
+    flex: 1;
+  }
+`;

Some files were not shown because too many files changed in this diff