Browse Source

feat(replays): Create a "URLWalker" breadcrumb component (#37038)

Create a component we're calling the 'url walker' which is a dumb name. But 'breadcrumbs' is overloaded like crazy already.

What we're doing is showing which pages the user took during their replay, their journey from start to finish. The first and last pages are visible, and the middle ones are behind a Hovercard.

This component shows up on the replay list page, and in the header of the details page. In the details page each item is clickable, taking you to that timestamp in the video.
Ryan Albrecht 2 years ago
parent
commit
076d29a597

+ 1 - 1
static/app/components/idBadge/userBadge.tsx

@@ -7,7 +7,7 @@ import {AvatarUser} from 'sentry/types';
 type Props = {
   avatarSize?: UserAvatar['props']['size'];
   className?: string;
-  displayEmail?: string;
+  displayEmail?: React.ReactNode | string;
   displayName?: React.ReactNode;
   hideEmail?: boolean;
   user?: AvatarUser;

+ 59 - 0
static/app/components/replays/walker/chevronDividedList.tsx

@@ -0,0 +1,59 @@
+import {ReactElement} from 'react';
+import styled from '@emotion/styled';
+
+import {IconChevron} from 'sentry/icons';
+import space from 'sentry/styles/space';
+
+type Props = {
+  items: ReactElement[];
+};
+
+function ChevronDividedList({items}: Props) {
+  return (
+    <List cols={items.length}>
+      {items.flatMap((item, i) => {
+        const li = <Item key={`${i}-item`}>{item}</Item>;
+
+        return i === 0
+          ? li
+          : [
+              <Item key={`${i}-chev`}>
+                <Chevron>
+                  <IconChevron color="gray300" size="xs" direction="right" />
+                </Chevron>
+              </Item>,
+              li,
+            ];
+      })}
+    </List>
+  );
+}
+
+const List = styled('ul')<{cols: number}>`
+  padding: 0;
+  margin: 0;
+  list-style: none;
+  display: grid;
+  gap: ${space(1)};
+  grid-template-columns: ${p =>
+    `minmax(auto, max-content) repeat(${
+      (p.cols - 2) * 2 + 1
+    }, max-content) minmax(auto, max-content)`};
+  flex-wrap: nowrap;
+  align-items: center;
+  overflow: hidden;
+`;
+
+const Item = styled('li')`
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+`;
+
+const Chevron = styled('span')`
+  color: ${p => p.theme.gray300};
+  font-size: ${p => p.theme.fontSizeSmall};
+  line-height: 1;
+`;
+
+export default ChevronDividedList;

+ 163 - 0
static/app/components/replays/walker/splitCrumbs.tsx

@@ -0,0 +1,163 @@
+import React from 'react';
+import styled from '@emotion/styled';
+import first from 'lodash/first';
+import last from 'lodash/last';
+
+import {Hovercard} from 'sentry/components/hovercard';
+import TextOverflow from 'sentry/components/textOverflow';
+import Tooltip from 'sentry/components/tooltip';
+import {tn} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {BreadcrumbTypeNavigation, Crumb} from 'sentry/types/breadcrumbs';
+import BreadcrumbItem from 'sentry/views/replays/detail/breadcrumbs/breadcrumbItem';
+
+type MaybeOnClickHandler = null | ((crumb: Crumb) => void);
+
+function splitCrumbs({
+  crumbs,
+  onClick,
+  startTimestamp,
+}: {
+  crumbs: BreadcrumbTypeNavigation[];
+  onClick: MaybeOnClickHandler;
+  startTimestamp: number;
+}) {
+  const firstUrl = first(crumbs)?.data?.to;
+  const summarizedCrumbs = crumbs.slice(1, -1) as Crumb[];
+  const lastUrl = last(crumbs)?.data?.to;
+
+  if (crumbs.length === 0) {
+    // This one shouldn't overflow, but by including the component css stays
+    // consistent with the other Segment types
+    return [
+      <Span key="summary">
+        <TextOverflow>{tn('%s Page', '%s Pages', 0)}</TextOverflow>
+      </Span>,
+    ];
+  }
+
+  if (crumbs.length === 1) {
+    return [
+      <SingleLinkSegment
+        key="single"
+        path={firstUrl}
+        onClick={onClick ? () => onClick(first(crumbs) as Crumb) : null}
+      />,
+    ];
+  }
+
+  if (crumbs.length === 2) {
+    return [
+      <SingleLinkSegment
+        key="first"
+        path={firstUrl}
+        onClick={onClick ? () => onClick(first(crumbs) as Crumb) : null}
+      />,
+      <SingleLinkSegment
+        key="last"
+        path={lastUrl}
+        onClick={onClick ? () => onClick(last(crumbs) as Crumb) : null}
+      />,
+    ];
+  }
+
+  return [
+    <SingleLinkSegment
+      key="first"
+      path={firstUrl}
+      onClick={onClick ? () => onClick(first(crumbs) as Crumb) : null}
+    />,
+    <SummarySegment
+      key="summary"
+      crumbs={summarizedCrumbs}
+      startTimestamp={startTimestamp}
+      handleOnClick={onClick}
+    />,
+    <SingleLinkSegment
+      key="last"
+      path={lastUrl}
+      onClick={onClick ? () => onClick(last(crumbs) as Crumb) : null}
+    />,
+  ];
+}
+
+function SingleLinkSegment({
+  onClick,
+  path,
+}: {
+  onClick: null | (() => void);
+  path: undefined | string;
+}) {
+  if (!path) {
+    return null;
+  }
+  const content = (
+    <Tooltip title={path}>
+      <TextOverflow ellipsisDirection="left">{path}</TextOverflow>
+    </Tooltip>
+  );
+  if (onClick) {
+    return (
+      <Link href="#" onClick={onClick}>
+        {content}
+      </Link>
+    );
+  }
+  return <Span>{content}</Span>;
+}
+
+function SummarySegment({
+  crumbs,
+  handleOnClick,
+  startTimestamp,
+}: {
+  crumbs: Crumb[];
+  handleOnClick: MaybeOnClickHandler;
+  startTimestamp: number;
+}) {
+  const summaryItems = crumbs.map(crumb => (
+    <BreadcrumbItem
+      key={crumb.id}
+      crumb={crumb}
+      startTimestamp={startTimestamp}
+      isHovered={false}
+      isSelected={false}
+      onClick={handleOnClick}
+    />
+  ));
+
+  return (
+    <Span>
+      <HalfPaddingHovercard body={summaryItems} position="right">
+        <TextOverflow>{tn('%s Page', '%s Pages', summaryItems.length)}</TextOverflow>
+      </HalfPaddingHovercard>
+    </Span>
+  );
+}
+
+const Span = styled('span')`
+  color: ${p => p.theme.subText};
+  font-size: ${p => p.theme.fontSizeSmall};
+  line-height: 0;
+`;
+
+const Link = styled('a')`
+  color: ${p => p.theme.subText};
+  font-size: ${p => p.theme.fontSizeSmall};
+  line-height: 0;
+  text-decoration: underline;
+`;
+
+const HalfPaddingHovercard = styled(
+  ({children, bodyClassName, ...props}: React.ComponentProps<typeof Hovercard>) => (
+    <Hovercard bodyClassName={bodyClassName || '' + ' half-padding'} {...props}>
+      {children}
+    </Hovercard>
+  )
+)`
+  .half-padding {
+    padding: ${space(0.5)};
+  }
+`;
+
+export default splitCrumbs;

+ 77 - 0
static/app/components/replays/walker/urlWalker.tsx

@@ -0,0 +1,77 @@
+import {memo, useCallback} from 'react';
+
+import {useReplayContext} from 'sentry/components/replays/replayContext';
+import {relativeTimeInMs} from 'sentry/components/replays/utils';
+import ChevronDividedList from 'sentry/components/replays/walker/chevronDividedList';
+import splitCrumbs from 'sentry/components/replays/walker/splitCrumbs';
+import {
+  BreadcrumbLevelType,
+  BreadcrumbType,
+  BreadcrumbTypeNavigation,
+  Crumb,
+} from 'sentry/types/breadcrumbs';
+import type {EventTransaction} from 'sentry/types/event';
+
+type CrumbProps = {
+  crumbs: Crumb[];
+  event: EventTransaction;
+};
+
+type StringProps = {
+  urls: string[];
+};
+
+export const CrumbWalker = memo(function CrumbWalker({crumbs, event}: CrumbProps) {
+  const {setCurrentTime} = useReplayContext();
+
+  const startTimestamp = event.startTimestamp;
+
+  const handleClick = useCallback(
+    (crumb: Crumb) => {
+      crumb.timestamp !== undefined
+        ? setCurrentTime(relativeTimeInMs(crumb.timestamp, startTimestamp))
+        : null;
+    },
+    [setCurrentTime, startTimestamp]
+  );
+
+  const navCrumbs = crumbs.filter(
+    crumb => crumb.type === BreadcrumbType.NAVIGATION
+  ) as BreadcrumbTypeNavigation[];
+
+  return (
+    <ChevronDividedList
+      items={splitCrumbs({
+        crumbs: navCrumbs,
+        startTimestamp,
+        onClick: handleClick,
+      })}
+    />
+  );
+});
+
+export const StringWalker = memo(function StringWalker({urls}: StringProps) {
+  return (
+    <ChevronDividedList
+      items={splitCrumbs({
+        crumbs: urls.map(urlToCrumb),
+        startTimestamp: 0,
+        onClick: null,
+      })}
+    />
+  );
+});
+
+function urlToCrumb(url: string) {
+  return {
+    type: BreadcrumbType.NAVIGATION,
+    category: BreadcrumbType.NAVIGATION,
+    level: BreadcrumbLevelType.INFO,
+    description: 'Navigation',
+
+    id: 0,
+    color: 'green300',
+    timestamp: undefined,
+    data: {to: url},
+  } as BreadcrumbTypeNavigation;
+}

+ 3 - 3
static/app/views/replays/detail/breadcrumbs/breadcrumbItem.tsx

@@ -15,7 +15,7 @@ interface Props {
   crumb: Crumb;
   isHovered: boolean;
   isSelected: boolean;
-  onClick: MouseCallback;
+  onClick: null | MouseCallback;
   startTimestamp: number;
   onMouseEnter?: MouseCallback;
   onMouseLeave?: MouseCallback;
@@ -41,13 +41,13 @@ function BreadcrumbItem({
     [onMouseLeave, crumb]
   );
   const handleClick = useCallback(
-    (e: React.MouseEvent<HTMLElement>) => onClick(crumb, e),
+    (e: React.MouseEvent<HTMLElement>) => onClick?.(crumb, e),
     [onClick, crumb]
   );
 
   return (
     <CrumbItem
-      as="button"
+      as={onClick ? 'button' : 'span'}
       onMouseEnter={handleMouseEnter}
       onMouseLeave={handleMouseLeave}
       onClick={handleClick}

+ 8 - 13
static/app/views/replays/detail/page.tsx

@@ -4,11 +4,11 @@ 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 {CrumbWalker} from 'sentry/components/replays/walker/urlWalker';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import space from 'sentry/styles/space';
 import type {Crumb} from 'sentry/types/breadcrumbs';
 import type {EventTransaction} from 'sentry/types/event';
-import getUrlPathname from 'sentry/utils/getUrlPathname';
 import EventMetaData, {
   HeaderPlaceholder,
 } from 'sentry/views/replays/detail/eventMetaData';
@@ -25,9 +25,6 @@ type Props = {
 function Page({children, crumbs, durationMS, event, orgId}: Props) {
   const title = event ? `${event.id} - Replays - ${orgId}` : `Replays - ${orgId}`;
 
-  const urlTag = event?.tags?.find(({key}) => key === 'url');
-  const pathname = getUrlPathname(urlTag?.value ?? '') ?? '';
-
   const header = (
     <Header>
       <HeaderContent>
@@ -37,7 +34,13 @@ function Page({children, crumbs, durationMS, event, orgId}: Props) {
         <FeatureFeedback featureName="replay" buttonProps={{size: 'xs'}} />
         <ChooseLayout />
       </ButtonActionsWrapper>
-      <SubHeading>{pathname || <HeaderPlaceholder />}</SubHeading>
+
+      {event && crumbs ? (
+        <CrumbWalker event={event} crumbs={crumbs} />
+      ) : (
+        <HeaderPlaceholder />
+      )}
+
       <MetaDataColumn>
         <EventMetaData crumbs={crumbs} durationMS={durationMS} event={event} />
       </MetaDataColumn>
@@ -72,14 +75,6 @@ const ButtonActionsWrapper = styled(Layout.HeaderActions)`
   gap: ${space(1)};
 `;
 
-const SubHeading = styled('div')`
-  font-size: ${p => p.theme.fontSizeMedium};
-  line-height: ${p => p.theme.text.lineHeightBody};
-  color: ${p => p.theme.subText};
-  align-self: end;
-  ${p => p.theme.overflowEllipsis};
-`;
-
 const MetaDataColumn = styled(Layout.HeaderActions)`
   padding-left: ${space(3)};
   align-self: end;

+ 2 - 2
static/app/views/replays/replayTable.tsx

@@ -6,11 +6,11 @@ import ProjectBadge from 'sentry/components/idBadge/projectBadge';
 import UserBadge from 'sentry/components/idBadge/userBadge';
 import Link from 'sentry/components/links/link';
 import Placeholder from 'sentry/components/placeholder';
+import {StringWalker} from 'sentry/components/replays/walker/urlWalker';
 import TimeSince from 'sentry/components/timeSince';
 import {IconCalendar} from 'sentry/icons';
 import space from 'sentry/styles/space';
 import {generateEventSlug} from 'sentry/utils/discover/urls';
-import getUrlPathname from 'sentry/utils/getUrlPathname';
 import useDiscoverQuery from 'sentry/utils/replays/hooks/useDiscoveryQuery';
 import theme from 'sentry/utils/theme';
 import useMedia from 'sentry/utils/useMedia';
@@ -85,7 +85,7 @@ function ReplayTable({replayList, idKey, showProjectColumn}: Props) {
               email: replay['user.email'] ?? '',
             }}
             // this is the subheading for the avatar, so displayEmail in this case is a misnomer
-            displayEmail={getUrlPathname(replay.url) ?? ''}
+            displayEmail={<StringWalker urls={[]} />}
           />
           {isScreenLarge && showProjectColumn && (
             <Item>