Browse Source

feat(replays): Create a demo page to showing the replay details page using height:100vh (#36078)

Create a new url + layout for the Replay Details

This layout has draggable panels that can be resized to adjust what information is displayed on the screen.

Component layout can be toggled between a 'sidebar' mode and a 'topbar' mode. In both cases the Timeline is across the top of the page.

Fixes #35048
Ryan Albrecht 2 years ago
parent
commit
ed6ab34a4e

+ 1 - 0
package.json

@@ -126,6 +126,7 @@
     "react-lazyload": "^2.3.0",
     "react-mentions": "4.4.2",
     "react-popper": "^2.3.0",
+    "react-resize-panel": "^0.3.5",
     "react-router": "3.2.0",
     "react-select": "3.1.0",
     "react-select-event": "5.5.0",

+ 47 - 0
static/app/components/replays/header/detailsPageBreadcrumbs.tsx

@@ -0,0 +1,47 @@
+import {Fragment} from 'react';
+
+import Breadcrumbs from 'sentry/components/breadcrumbs';
+import FeatureBadge from 'sentry/components/featureBadge';
+import {t} from 'sentry/locale';
+import {Event} from 'sentry/types/event';
+
+type Props = {
+  eventSlug: string;
+  orgId: string;
+  event?: Event;
+};
+
+function getUsernameFromEvent({eventSlug, event}: Pick<Props, 'event' | 'eventSlug'>) {
+  const user = event?.user;
+
+  if (!user) {
+    return eventSlug;
+  }
+
+  return user.name || user.email || user.username || user.ip_address;
+}
+
+function DetailsPageBreadcrumbs({orgId, event, eventSlug}: Props) {
+  const username = getUsernameFromEvent({event, eventSlug});
+
+  return (
+    <Breadcrumbs
+      crumbs={[
+        {
+          to: `/organizations/${orgId}/replays/`,
+          label: t('Replays'),
+        },
+        {
+          label: (
+            <Fragment>
+              {username}
+              <FeatureBadge type="alpha" />
+            </Fragment>
+          ),
+        },
+      ]}
+    />
+  );
+}
+
+export default DetailsPageBreadcrumbs;

+ 4 - 0
static/app/routes.tsx

@@ -1041,6 +1041,10 @@ function buildRoutes() {
         path=":eventSlug/"
         component={make(() => import('sentry/views/replays/details'))}
       />
+      <Route
+        path=":eventSlug/v2/"
+        component={make(() => import('sentry/views/replays/details_v2'))}
+      />
     </Route>
   );
 

+ 7 - 2
static/app/views/replays/detail/focusTabs.tsx

@@ -1,4 +1,5 @@
 import React from 'react';
+import styled from '@emotion/styled';
 
 import NavTabs from 'sentry/components/navTabs';
 import {t} from 'sentry/locale';
@@ -20,12 +21,16 @@ function FocusTabs({}: Props) {
   return (
     <NavTabs underlined>
       {TABS.map(tab => (
-        <li key={tab} className={active === tab.toLowerCase() ? 'active' : ''}>
+        <Tab key={tab} className={active === tab.toLowerCase() ? 'active' : ''}>
           <a href={`#${tab.toLowerCase()}`}>{tab}</a>
-        </li>
+        </Tab>
       ))}
     </NavTabs>
   );
 }
 
+const Tab = styled('li')`
+  z-index: ${p => p.theme.zIndex.initial + 1};
+`;
+
 export default FocusTabs;

+ 83 - 0
static/app/views/replays/detail/layout/container.tsx

@@ -0,0 +1,83 @@
+import styled from '@emotion/styled';
+
+import space from 'sentry/styles/space';
+import theme from 'sentry/utils/theme';
+
+import {CLASSNAMES} from './resizePanel';
+
+// This is the generated SVG from https://github.com/getsentry/sentry/blob/master/static/app/icons/iconGrabbable.tsx
+// I couldn't sort out how to extract it from the react component. I think it
+// could be done react-dom-server or to render it inside an unmounted dom node
+// then copy the html content. All that seemed slower to build and slower to
+// exec compared to having an encoded svg.
+const GrabberColor = encodeURIComponent(theme.gray300);
+const GrabberSVG =
+  `url('data:image/svg+xml,` +
+  `%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="${GrabberColor}" height="16px" width="16px"%3E` +
+  '%3Ccircle cx="4.73" cy="8" r="1.31"%3E%3C/circle%3E' +
+  '%3Ccircle cx="4.73" cy="1.31" r="1.31"%3E%3C/circle%3E' +
+  '%3Ccircle cx="11.27" cy="8" r="1.31"%3E%3C/circle%3E' +
+  '%3Ccircle cx="11.27" cy="1.31" r="1.31"%3E%3C/circle%3E' +
+  '%3Ccircle cx="4.73" cy="14.69" r="1.31"%3E%3C/circle%3E' +
+  '%3Ccircle cx="11.27" cy="14.69" r="1.31"%3E%3C/circle%3E' +
+  '%3C/svg%3E' +
+  `')`;
+
+const Container = styled('div')`
+  width: 100%;
+  height: 100%;
+  max-height: 100%;
+  display: flex;
+  flex-flow: nowrap column;
+  overflow: hidden;
+  padding: ${space(2)};
+
+  .${CLASSNAMES.bar.width} {
+    cursor: ew-resize;
+    height: 100%;
+    width: ${space(2)};
+  }
+
+  .${CLASSNAMES.bar.height} {
+    cursor: ns-resize;
+    height: ${space(2)};
+    width: 100%;
+  }
+  .${CLASSNAMES.bar.height}.overlapDown {
+    height: calc(16px + 34px); /* Spacing between components + height of <FocusTabs> */
+    margin-bottom: -34px; /* The height of the <FocusTabs> text + border */
+    z-index: ${p => p.theme.zIndex.initial};
+  }
+
+  .${CLASSNAMES.bar.height}, .${CLASSNAMES.bar.width} {
+    background: transparent;
+    display: flex;
+    align-items: center;
+    align-content: center;
+    justify-content: center;
+  }
+  .${CLASSNAMES.bar.height}:hover, .${CLASSNAMES.bar.width}:hover {
+    background: ${p => p.theme.hover};
+  }
+
+  .${CLASSNAMES.handle.width} {
+    height: ${space(3)};
+    width: ${space(2)};
+  }
+
+  .${CLASSNAMES.handle.height} {
+    height: ${space(2)};
+    width: ${space(3)};
+    transform: rotate(90deg);
+  }
+
+  .${CLASSNAMES.handle.height} > span,
+  .${CLASSNAMES.handle.width} > span {
+    display: block;
+    background: transparent ${GrabberSVG} center center no-repeat;
+    width: 100%;
+    height: 100%;
+  }
+`;
+
+export default Container;

+ 173 - 0
static/app/views/replays/detail/layout/index.tsx

@@ -0,0 +1,173 @@
+import styled from '@emotion/styled';
+
+import ErrorBoundary from 'sentry/components/errorBoundary';
+import ReplayTimeline from 'sentry/components/replays/breadcrumbs/replayTimeline';
+import ReplayView from 'sentry/components/replays/replayView';
+import space from 'sentry/styles/space';
+import useFullscreen from 'sentry/utils/replays/hooks/useFullscreen';
+import Breadcrumbs from 'sentry/views/replays/detail/breadcrumbs';
+import FocusArea from 'sentry/views/replays/detail/focusArea';
+import FocusTabs from 'sentry/views/replays/detail/focusTabs';
+
+import Container from './container';
+import ResizePanel from './resizePanel';
+
+type Layout =
+  /**
+   * ### Sidebar
+   * ┌───────────────────┐
+   * │ Timeline          │
+   * ├──────────┬────────┤
+   * │ Details  > Video  │
+   * │          >        │
+   * │          >^^^^^^^^┤
+   * │          > Crumbs │
+   * │          >        │
+   * └──────────┴────────┘
+   */
+  | 'sidebar'
+  /**
+   * ### Topbar
+   *┌────────────────────┐
+   *│ Timeline           │
+   *├───────────┬────────┤
+   *│ Video     │ Crumbs │
+   *│           │        │
+   *├^^^^^^^^^^^^^^^^^^^^┤
+   *│ Details            │
+   *│                    │
+   *└────────────────────┘
+   */
+  | 'topbar';
+
+const SIDEBAR_MIN_WIDTH = 325;
+const TOPBAR_MIN_HEIGHT = 325;
+
+type Props = {
+  layout?: Layout;
+  showCrumbs?: boolean;
+  showTimeline?: boolean;
+  showVideo?: boolean;
+};
+
+function ReplayLayout({
+  layout = 'topbar',
+  showCrumbs = true,
+  showTimeline = true,
+  showVideo = true,
+}: Props) {
+  const {ref: fullscreenRef, isFullscreen, toggle: toggleFullscreen} = useFullscreen();
+
+  const timeline = showTimeline ? (
+    <TimelineSection>
+      <ErrorBoundary mini>
+        <ReplayTimeline />
+      </ErrorBoundary>
+    </TimelineSection>
+  ) : null;
+
+  const video = showVideo ? (
+    <VideoSection ref={fullscreenRef}>
+      <ErrorBoundary mini>
+        <ReplayView toggleFullscreen={toggleFullscreen} isFullscreen={isFullscreen} />
+      </ErrorBoundary>
+    </VideoSection>
+  ) : null;
+
+  const crumbs = showCrumbs ? (
+    <BreadcrumbSection>
+      <ErrorBoundary mini>
+        <Breadcrumbs />
+      </ErrorBoundary>
+    </BreadcrumbSection>
+  ) : null;
+
+  const content = (
+    <ContentSection>
+      <ErrorBoundary mini>
+        <FocusTabs />
+        <FocusArea />
+      </ErrorBoundary>
+    </ContentSection>
+  );
+
+  if (layout === 'sidebar') {
+    return (
+      <Container>
+        {timeline}
+        <PageRow>
+          {content}
+          <ResizePanel direction="w" minWidth={SIDEBAR_MIN_WIDTH}>
+            <SidebarSection>
+              {video ? <ResizePanel direction="s">{video}</ResizePanel> : null}
+              {crumbs}
+            </SidebarSection>
+          </ResizePanel>
+        </PageRow>
+      </Container>
+    );
+  }
+
+  // layout === 'topbar' or default
+  return (
+    <Container>
+      {timeline}
+      <ResizePanel
+        direction="s"
+        minHeight={TOPBAR_MIN_HEIGHT}
+        modifierClass="overlapDown"
+      >
+        <TopbarSection>
+          {video}
+          {crumbs}
+        </TopbarSection>
+      </ResizePanel>
+      {content}
+    </Container>
+  );
+}
+
+const PageColumn = styled('section')`
+  display: flex;
+  flex-grow: 1;
+  flex-wrap: nowrap;
+  flex-direction: column;
+`;
+
+const PageRow = styled(PageColumn)`
+  flex-direction: row;
+`;
+
+const TimelineSection = styled(PageColumn)`
+  flex-grow: 0;
+`;
+
+const ContentSection = styled(PageColumn)`
+  flex-grow: 3; /* Higher growth than SidebarSection or TopVideoSection */
+
+  height: 100%;
+  min-height: 300px;
+  width: 100%;
+`;
+
+const VideoSection = styled(PageColumn)`
+  flex-grow: 2;
+`;
+
+const BreadcrumbSection = styled(PageColumn)``;
+
+const SidebarSection = styled(PageColumn)`
+  min-width: ${SIDEBAR_MIN_WIDTH}px;
+`;
+
+const TopbarSection = styled(PageRow)`
+  height: ${TOPBAR_MIN_HEIGHT}px;
+  min-height: ${TOPBAR_MIN_HEIGHT}px;
+
+  ${BreadcrumbSection} {
+    max-width: ${SIDEBAR_MIN_WIDTH}px;
+    margin-left: ${space(2)};
+  }
+`;
+
+export default ReplayLayout;

+ 43 - 0
static/app/views/replays/detail/layout/resizePanel.tsx

@@ -0,0 +1,43 @@
+import BaseResizePanel from 'react-resize-panel';
+import styled from '@emotion/styled';
+
+type Props = {
+  direction: 'n' | 'e' | 's' | 'w';
+  minHeight?: number;
+  minWidth?: number;
+  modifierClass?: string;
+};
+
+export const CLASSNAMES = {
+  bar: {
+    height: 'resizeHeightBar',
+    width: 'resizeWidthBar',
+  },
+  handle: {
+    height: 'resizeHeightHandle',
+    width: 'resizeWidthHandle',
+  },
+};
+
+const ResizePanel = styled(function ResizePanelContent({
+  direction,
+  modifierClass = '',
+  ...props
+}: Props) {
+  const movesUpDown = ['n', 's'].includes(direction);
+  const borderClass = movesUpDown ? CLASSNAMES.bar.height : CLASSNAMES.bar.width;
+  const handleClass = movesUpDown ? CLASSNAMES.handle.height : CLASSNAMES.handle.width;
+
+  return (
+    <BaseResizePanel
+      direction={direction}
+      {...props}
+      borderClass={`${borderClass} ${modifierClass}`}
+      handleClass={`${handleClass} ${modifierClass}`}
+    />
+  );
+})`
+  position: relative;
+`;
+
+export default ResizePanel;

+ 70 - 0
static/app/views/replays/detail/page.tsx

@@ -0,0 +1,70 @@
+import {ReactNode} from 'react';
+import styled from '@emotion/styled';
+
+import {FeatureFeedback} from 'sentry/components/featureFeedback';
+import * as Layout from 'sentry/components/layouts/thirds';
+import DetailsPageBreadcrumbs from 'sentry/components/replays/header/detailsPageBreadcrumbs';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import space from 'sentry/styles/space';
+import {Event} from 'sentry/types/event';
+
+type Props = {
+  children: ReactNode;
+  eventSlug: string;
+  orgId: string;
+  event?: Event;
+};
+
+function Page({children, event, orgId, eventSlug}: Props) {
+  const title = event ? `${event.id} - Replays - ${orgId}` : `Replays - ${orgId}`;
+
+  return (
+    <SentryDocumentTitle title={title}>
+      <FullViewport>
+        <Layout.Header>
+          <Layout.HeaderContent>
+            <DetailsPageBreadcrumbs orgId={orgId} event={event} eventSlug={eventSlug} />
+          </Layout.HeaderContent>
+          <ButtonActionsWrapper>
+            <FeatureFeedback featureName="replay" buttonProps={{size: 'small'}} />
+          </ButtonActionsWrapper>
+        </Layout.Header>
+        <FullViewportContent>{children}</FullViewportContent>
+      </FullViewport>
+    </SentryDocumentTitle>
+  );
+}
+
+// TODO(replay); This could make a lot of sense to put inside HeaderActions by default
+const ButtonActionsWrapper = styled(Layout.HeaderActions)`
+  display: grid;
+  grid-template-columns: repeat(2, max-content);
+  justify-content: flex-end;
+  gap: ${space(1)};
+`;
+
+const FullViewport = styled('div')`
+  height: 100vh;
+  width: 100%;
+
+  display: flex;
+  flex-flow: nowrap column;
+  flex-direction: column;
+  overflow: hidden;
+
+  /*
+   * The footer component is a sibling of this div.
+   * Remove it so the replay can take up the
+   * entire screen.
+   */
+  ~ footer {
+    display: none;
+  }
+`;
+
+const FullViewportContent = styled('section')`
+  flex-grow: 1;
+  background: ${p => p.theme.background};
+`;
+
+export default Page;

+ 69 - 0
static/app/views/replays/details_v2.tsx

@@ -0,0 +1,69 @@
+import {Fragment} from 'react';
+
+import DetailedError from 'sentry/components/errors/detailedError';
+import NotFound from 'sentry/components/errors/notFound';
+import {Provider as ReplayContextProvider} from 'sentry/components/replays/replayContext';
+import {t} from 'sentry/locale';
+import {PageContent} from 'sentry/styles/organization';
+import useReplayData from 'sentry/utils/replays/hooks/useReplayData';
+import {useRouteContext} from 'sentry/utils/useRouteContext';
+import Layout from 'sentry/views/replays/detail/layout';
+import Page from 'sentry/views/replays/detail/page';
+
+function ReplayDetails() {
+  const {
+    location,
+    params: {eventSlug, orgId},
+  } = useRouteContext();
+
+  const {
+    t: initialTimeOffset, // Time, in seconds, where the video should start
+  } = location.query;
+
+  const {fetching, onRetry, replay} = useReplayData({
+    eventSlug,
+    orgId,
+  });
+
+  if (!fetching && !replay) {
+    return (
+      <Page eventSlug={eventSlug} orgId={orgId}>
+        <PageContent>
+          <NotFound />
+        </PageContent>
+      </Page>
+    );
+  }
+
+  if (!fetching && replay && replay.getRRWebEvents().length < 2) {
+    return (
+      <Page eventSlug={eventSlug} orgId={orgId} event={replay.getEvent()}>
+        <DetailedError
+          onRetry={onRetry}
+          hideSupportLinks
+          heading={t('Expected two or more replay events')}
+          message={
+            <Fragment>
+              <p>{t('This Replay may not have captured any user actions.')}</p>
+              <p>
+                {t(
+                  'Or there may be an issue loading the actions from the server, click to try loading the Replay again.'
+                )}
+              </p>
+            </Fragment>
+          }
+        />
+      </Page>
+    );
+  }
+
+  return (
+    <Page eventSlug={eventSlug} orgId={orgId} event={replay?.getEvent()}>
+      <ReplayContextProvider replay={replay} initialTimeOffset={initialTimeOffset}>
+        <Layout />
+      </ReplayContextProvider>
+    </Page>
+  );
+}
+
+export default ReplayDetails;

+ 15 - 0
yarn.lock

@@ -6152,6 +6152,11 @@ case-sensitive-paths-webpack-plugin@^2.3.0:
   resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4"
   integrity sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==
 
+cash-dom@^4.1.5:
+  version "4.1.5"
+  resolved "https://registry.yarnpkg.com/cash-dom/-/cash-dom-4.1.5.tgz#0ef0cf205bc7603aa4e2dfada5808442a7a0e6ca"
+  integrity sha512-E6MO0A6ms5iZPtexznQXWRkFEvqdPqCmdx/SiJr2PnhOQNhZNfALkLG5t83Hk3J5JELzED7PJuzhMoS2tT64XA==
+
 cbor-web@^8.1.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/cbor-web/-/cbor-web-8.1.0.tgz#c1148e91ca6bfc0f5c07c1df164854596e2e33d6"
@@ -13087,6 +13092,16 @@ react-resizable@^3.0.4:
     prop-types "15.x"
     react-draggable "^4.0.3"
 
+react-resize-panel@^0.3.5:
+  version "0.3.5"
+  resolved "https://registry.yarnpkg.com/react-resize-panel/-/react-resize-panel-0.3.5.tgz#43aa3450bf5b5a2566b40c4201445ced96c2a905"
+  integrity sha512-iyHOFTrSt+WV4Ilzi81x6KH3FU7VsGP736rmxepwGrgAEATmCvXzZdluTm3NpsptP7aC3hLODmXwnxusyA393A==
+  dependencies:
+    cash-dom "^4.1.5"
+    classnames "^2.2.6"
+    lodash.debounce "^4.0.8"
+    react-draggable "^4.0.3"
+
 react-router@3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/react-router/-/react-router-3.2.0.tgz#62b6279d589b70b34e265113e4c0a9261a02ed36"