Browse Source

feat(view-hierarchy): Render collapsible tree (#42715)

Takes a view hierarchy attachment and renders it as a collapsible tree.

Currently only supports a single hierarchy and single window.

Closes #42719
Nar Saynorath 2 years ago
parent
commit
21d193e7d6

+ 13 - 0
static/app/components/events/eventEntries.tsx

@@ -52,6 +52,7 @@ import findBestThread from './interfaces/threads/threadSelector/findBestThread';
 import getThreadException from './interfaces/threads/threadSelector/getThreadException';
 import EventEntry from './eventEntry';
 import EventTagsAndScreenshot from './eventTagsAndScreenshot';
+import {EventViewHierarchy} from './eventViewHierarchy';
 
 const MINIFIED_DATA_JAVA_EVENT_REGEX_MATCH =
   /^(([\w\$]\.[\w\$]{1,2})|([\w\$]{2}\.[\w\$]\.[\w\$]))(\.|$)/g;
@@ -371,6 +372,18 @@ const EventEntries = ({
       {event && !objectIsEmpty(event.context) && <EventExtraData event={event} />}
       {event && !objectIsEmpty(event.packages) && <EventPackageData event={event} />}
       {event && !objectIsEmpty(event.device) && <EventDevice event={event} />}
+      {!isShare &&
+        organization.features?.includes('mobile-view-hierarchies') &&
+        hasEventAttachmentsFeature &&
+        !!attachments.filter(attachment => attachment.type === 'event.view_hierarchy')
+          .length && (
+          <EventViewHierarchy
+            projectSlug={projectSlug}
+            viewHierarchies={attachments.filter(
+              attachment => attachment.type === 'event.view_hierarchy'
+            )}
+          />
+        )}
       {!isShare && hasEventAttachmentsFeature && (
         <EventAttachments
           event={event}

+ 84 - 0
static/app/components/events/eventViewHierarchy.tsx

@@ -0,0 +1,84 @@
+import {useState} from 'react';
+import isEqual from 'lodash/isEqual';
+
+import {getAttachmentUrl} from 'sentry/components/events/attachmentViewers/utils';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {tn} from 'sentry/locale';
+import {EventAttachment} from 'sentry/types';
+import {uniqueId} from 'sentry/utils/guid';
+import {useQuery} from 'sentry/utils/queryClient';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import EventDataSection from './eventDataSection';
+import {ViewHierarchy, ViewHierarchyData} from './viewHierarchy';
+
+const DEFAULT_RESPONSE: ViewHierarchyData = {rendering_system: '', windows: []};
+const FIVE_SECONDS_IN_MS = 5 * 1000;
+
+function fillWithUniqueIds(hierarchy) {
+  return {
+    ...hierarchy,
+    id: uniqueId(),
+    children: hierarchy.children?.map(fillWithUniqueIds) ?? [],
+  };
+}
+
+type Props = {
+  projectSlug: string;
+  viewHierarchies: EventAttachment[];
+};
+
+function EventViewHierarchy({projectSlug, viewHierarchies}: Props) {
+  const [selectedViewHierarchy] = useState(0);
+  const api = useApi();
+  const organization = useOrganization();
+
+  const hierarchyMeta = viewHierarchies[selectedViewHierarchy];
+  const {isLoading, data} = useQuery<ViewHierarchyData>(
+    [`viewHierarchies.${hierarchyMeta.id}`],
+    async () => {
+      const response = await api.requestPromise(
+        getAttachmentUrl({
+          attachment: hierarchyMeta,
+          eventId: hierarchyMeta.event_id,
+          orgId: organization.slug,
+          projectId: projectSlug,
+        }),
+        {
+          method: 'GET',
+        }
+      );
+
+      if (!response) {
+        return DEFAULT_RESPONSE;
+      }
+
+      const JSONdata = JSON.parse(response);
+
+      return {
+        rendering_system: JSONdata.rendering_system,
+        // Recursively add unique IDs to the nodes for rendering the tree,
+        // and to correlate elements when hovering between tree and wireframe
+        windows: JSONdata.windows.map(fillWithUniqueIds),
+      };
+    },
+    {staleTime: FIVE_SECONDS_IN_MS, refetchOnWindowFocus: false}
+  );
+
+  // TODO(nar): This loading behaviour is subject to change
+  if (isLoading || !data || isEqual(DEFAULT_RESPONSE, data)) {
+    return <LoadingIndicator />;
+  }
+
+  return (
+    <EventDataSection
+      type="view_hierarchy"
+      title={tn('View Hierarchy', 'View Hierarchies', viewHierarchies.length)}
+    >
+      <ViewHierarchy viewHierarchy={data} />
+    </EventDataSection>
+  );
+}
+
+export {EventViewHierarchy};

+ 60 - 0
static/app/components/events/viewHierarchy/index.tsx

@@ -0,0 +1,60 @@
+import {Fragment, useState} from 'react';
+import styled from '@emotion/styled';
+
+import space from 'sentry/styles/space';
+
+import {RenderingSystem} from './renderingSystem';
+import {Tree} from './tree';
+
+export type ViewHierarchyWindow = {
+  alpha: number;
+  height: number;
+  id: string;
+  type: string;
+  visible: boolean;
+  width: number;
+  x: number;
+  y: number;
+  children?: ViewHierarchyWindow[];
+  depth?: number;
+  identifier?: string;
+};
+
+export type ViewHierarchyData = {
+  rendering_system: string;
+  windows: ViewHierarchyWindow[];
+};
+
+type ViewHierarchyProps = {
+  viewHierarchy: ViewHierarchyData;
+};
+
+function ViewHierarchy({viewHierarchy}: ViewHierarchyProps) {
+  const [selectedWindow] = useState(0);
+  return (
+    <Fragment>
+      <RenderingSystem system={viewHierarchy.rendering_system} />
+      <TreeContainer>
+        <Tree<ViewHierarchyWindow>
+          data={viewHierarchy.windows[selectedWindow]}
+          getNodeLabel={({identifier, type}) =>
+            identifier ? `${type} - ${identifier}` : type
+          }
+          isRoot
+        />
+      </TreeContainer>
+    </Fragment>
+  );
+}
+
+export {ViewHierarchy};
+
+const TreeContainer = styled('div')`
+  max-height: 500px;
+  overflow: auto;
+  background-color: ${p => p.theme.surface100};
+  border: 1px solid ${p => p.theme.gray100};
+  border-radius: ${p => p.theme.borderRadius};
+  padding: ${space(1.5)} 0;
+  display: block;
+`;

+ 18 - 0
static/app/components/events/viewHierarchy/renderingSystem.tsx

@@ -0,0 +1,18 @@
+import styled from '@emotion/styled';
+
+import Pill from 'sentry/components/pill';
+import {t} from 'sentry/locale';
+
+type RenderingSystemProps = {
+  system?: string;
+};
+
+function RenderingSystem({system}: RenderingSystemProps) {
+  return <StyledPill name={t('Rendering System')} value={system ?? t('Unknown')} />;
+}
+
+export {RenderingSystem};
+
+const StyledPill = styled(Pill)`
+  width: max-content;
+`;

+ 52 - 0
static/app/components/events/viewHierarchy/tree.spec.tsx

@@ -0,0 +1,52 @@
+import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary';
+
+import {Tree} from './tree';
+
+const DEFAULT_VALUES = {alpha: 1, height: 1, width: 1, x: 1, y: 1, visible: true};
+const MOCK_DATA = {
+  ...DEFAULT_VALUES,
+  id: 'parent',
+  type: 'Container',
+  children: [
+    {
+      ...DEFAULT_VALUES,
+      id: 'intermediate',
+      type: 'Nested Container',
+      children: [
+        {
+          ...DEFAULT_VALUES,
+          id: 'leaf',
+          type: 'Text',
+          children: [],
+        },
+      ],
+    },
+  ],
+};
+
+describe('View Hierarchy Tree', function () {
+  it('renders nested JSON', function () {
+    render(<Tree data={MOCK_DATA} getNodeLabel={({type}) => type} />);
+
+    expect(screen.getByRole('listitem', {name: 'Container'})).toBeVisible();
+    expect(screen.getByRole('listitem', {name: 'Nested Container'})).toBeVisible();
+    expect(screen.getByRole('listitem', {name: 'Text'})).toBeVisible();
+  });
+
+  it('can collapse and expand sections with children', function () {
+    render(<Tree data={MOCK_DATA} getNodeLabel={({type}) => type} />);
+
+    userEvent.click(
+      within(screen.getByRole('listitem', {name: 'Nested Container'})).getByLabelText(
+        'Collapse'
+      )
+    );
+    expect(screen.getByRole('listitem', {name: 'Text'})).not.toBeVisible();
+    userEvent.click(
+      within(screen.getByRole('listitem', {name: 'Nested Container'})).getByLabelText(
+        'Expand'
+      )
+    );
+    expect(screen.queryByRole('listitem', {name: 'Text'})).toBeVisible();
+  });
+});

+ 118 - 0
static/app/components/events/viewHierarchy/tree.tsx

@@ -0,0 +1,118 @@
+import {ReactNode, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {IconAdd, IconSubtract} from 'sentry/icons';
+import {t} from 'sentry/locale';
+
+type NodeProps = {
+  id: string;
+  label: string;
+  children?: ReactNode;
+  collapsible?: boolean;
+};
+
+function Node({label, id, children, collapsible}: NodeProps) {
+  const [isExpanded, setIsExpanded] = useState(true);
+  return (
+    <NodeContents aria-labelledby={`${id}-title`}>
+      {
+        <details id={id} open={isExpanded} onClick={e => e.preventDefault()}>
+          <summary>
+            {collapsible && (
+              <IconWrapper
+                aria-controls={id}
+                aria-label={isExpanded ? t('Collapse') : t('Expand')}
+                aria-expanded={isExpanded}
+                isExpanded={isExpanded}
+                onClick={() => setIsExpanded(!isExpanded)}
+              >
+                {isExpanded ? (
+                  <IconSubtract legacySize="9px" color="white" />
+                ) : (
+                  <IconAdd legacySize="9px" color="white" />
+                )}
+              </IconWrapper>
+            )}
+            <NodeTitle id={`${id}-title`}>{label}</NodeTitle>
+          </summary>
+          {children}
+        </details>
+      }
+    </NodeContents>
+  );
+}
+
+type TreeData<T> = T & {id: string; children?: TreeData<T>[]};
+
+type TreeProps<T> = {
+  data: TreeData<T>;
+  getNodeLabel: (data: TreeData<T>) => string;
+  isRoot?: boolean;
+};
+
+function Tree<T>({data, isRoot, getNodeLabel}: TreeProps<T>) {
+  const {id, children} = data;
+  if (!children?.length) {
+    return <Node label={getNodeLabel(data)} id={id} />;
+  }
+
+  const treeNode = (
+    <Node label={getNodeLabel(data)} id={id} collapsible>
+      <ChildList>
+        {children.map(element => (
+          <Tree key={element.id} data={element} getNodeLabel={getNodeLabel} />
+        ))}
+      </ChildList>
+    </Node>
+  );
+
+  return isRoot ? <RootList>{treeNode}</RootList> : treeNode;
+}
+
+export {Tree};
+
+const RootList = styled('ul')`
+  margin-bottom: 0;
+`;
+
+const ChildList = styled('ul')`
+  border-left: 1px solid ${p => p.theme.gray200};
+  margin-left: 5px;
+`;
+
+const NodeContents = styled('li')`
+  padding-left: 0;
+  display: block;
+`;
+
+// TODO(nar): Clicking the title will open more information
+// about the node, currently this does nothing
+const NodeTitle = styled('span')`
+  cursor: pointer;
+`;
+
+const IconWrapper = styled('button')<{isExpanded: boolean}>`
+  padding: 0;
+  border-radius: 2px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  margin-right: 4px;
+  ${p =>
+    p.isExpanded
+      ? `
+          background: ${p.theme.gray300};
+          border: 1px solid ${p.theme.gray300};
+          &:hover {
+            background: ${p.theme.gray400};
+          }
+        `
+      : `
+          background: ${p.theme.blue300};
+          border: 1px solid ${p.theme.blue300};
+          &:hover {
+            background: ${p.theme.blue200};
+          }
+        `}
+`;