123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- import type {CSSProperties, MouseEvent} from 'react';
- import {isValidElement, memo} from 'react';
- import styled from '@emotion/styled';
- import beautify from 'js-beautify';
- import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
- import {CodeSnippet} from 'sentry/components/codeSnippet';
- import ErrorBoundary from 'sentry/components/errorBoundary';
- import Link from 'sentry/components/links/link';
- import ObjectInspector from 'sentry/components/objectInspector';
- import PanelItem from 'sentry/components/panels/panelItem';
- import {Flex} from 'sentry/components/profiling/flex';
- import {OpenReplayComparisonButton} from 'sentry/components/replays/breadcrumbs/openReplayComparisonButton';
- import {useReplayContext} from 'sentry/components/replays/replayContext';
- import {useReplayGroupContext} from 'sentry/components/replays/replayGroupContext';
- import {Tooltip} from 'sentry/components/tooltip';
- import {space} from 'sentry/styles/space';
- import type {Extraction} from 'sentry/utils/replays/extractDomNodes';
- import getFrameDetails from 'sentry/utils/replays/getFrameDetails';
- import type {ErrorFrame, FeedbackFrame, ReplayFrame} from 'sentry/utils/replays/types';
- import {isErrorFrame, isFeedbackFrame} from 'sentry/utils/replays/types';
- import useOrganization from 'sentry/utils/useOrganization';
- import useProjectFromSlug from 'sentry/utils/useProjectFromSlug';
- import IconWrapper from 'sentry/views/replays/detail/iconWrapper';
- import TimestampButton from 'sentry/views/replays/detail/timestampButton';
- type MouseCallback = (frame: ReplayFrame, e: React.MouseEvent<HTMLElement>) => void;
- const FRAMES_WITH_BUTTONS = ['replay.hydrate-error'];
- interface Props {
- extraction: Extraction | undefined;
- frame: ReplayFrame;
- onClick: null | MouseCallback;
- onInspectorExpanded: (
- path: string,
- expandedState: Record<string, boolean>,
- event: MouseEvent<HTMLDivElement>
- ) => void;
- onMouseEnter: MouseCallback;
- onMouseLeave: MouseCallback;
- startTimestampMs: number;
- className?: string;
- expandPaths?: string[];
- style?: CSSProperties;
- }
- function BreadcrumbItem({
- className,
- extraction,
- frame,
- expandPaths,
- onClick,
- onInspectorExpanded,
- onMouseEnter,
- onMouseLeave,
- startTimestampMs,
- style,
- }: Props) {
- const {color, description, title, icon} = getFrameDetails(frame);
- const {replay} = useReplayContext();
- const forceSpan = 'category' in frame && FRAMES_WITH_BUTTONS.includes(frame.category);
- return (
- <CrumbItem
- isErrorFrame={isErrorFrame(frame)}
- as={onClick && !forceSpan ? 'button' : 'span'}
- onClick={e => onClick?.(frame, e)}
- onMouseEnter={e => onMouseEnter(frame, e)}
- onMouseLeave={e => onMouseLeave(frame, e)}
- style={style}
- className={className}
- >
- <IconWrapper color={color} hasOccurred>
- {icon}
- </IconWrapper>
- <ErrorBoundary mini>
- <CrumbDetails>
- <Flex column>
- <TitleContainer>
- {<Title>{title}</Title>}
- {onClick ? (
- <TimestampButton
- startTimestampMs={startTimestampMs}
- timestampMs={frame.timestampMs}
- />
- ) : null}
- </TitleContainer>
- {typeof description === 'string' ||
- (description !== undefined && isValidElement(description)) ? (
- <Description title={description} showOnlyOnOverflow isHoverable>
- {description}
- </Description>
- ) : (
- <InspectorWrapper>
- <ObjectInspector
- data={description}
- expandPaths={expandPaths}
- onExpand={onInspectorExpanded}
- theme={{
- TREENODE_FONT_SIZE: '0.7rem',
- ARROW_FONT_SIZE: '0.5rem',
- }}
- />
- </InspectorWrapper>
- )}
- </Flex>
- {'data' in frame && frame.data && 'mutations' in frame.data ? (
- <div>
- <OpenReplayComparisonButton
- replay={replay}
- leftTimestamp={frame.offsetMs}
- rightTimestamp={
- (frame.data.mutations.next?.timestamp ?? 0) -
- (replay?.getReplay().started_at.getTime() ?? 0)
- }
- />
- </div>
- ) : null}
- {extraction?.html ? (
- <CodeContainer>
- <CodeSnippet language="html" hideCopyButton>
- {beautify.html(extraction?.html, {indent_size: 2})}
- </CodeSnippet>
- </CodeContainer>
- ) : null}
- {isErrorFrame(frame) || isFeedbackFrame(frame) ? (
- <CrumbErrorIssue frame={frame} />
- ) : null}
- </CrumbDetails>
- </ErrorBoundary>
- </CrumbItem>
- );
- }
- function CrumbErrorIssue({frame}: {frame: FeedbackFrame | ErrorFrame}) {
- const organization = useOrganization();
- const project = useProjectFromSlug({organization, projectSlug: frame.data.projectSlug});
- const {groupId} = useReplayGroupContext();
- const projectAvatar = project ? <ProjectAvatar project={project} size={16} /> : null;
- if (String(frame.data.groupId) === groupId) {
- return (
- <CrumbIssueWrapper>
- {projectAvatar}
- {frame.data.groupShortId}
- </CrumbIssueWrapper>
- );
- }
- return (
- <CrumbIssueWrapper>
- {projectAvatar}
- <Link
- to={
- isFeedbackFrame(frame)
- ? {
- pathname: `/organizations/${organization.slug}/feedback/`,
- query: {feedbackSlug: `${frame.data.projectSlug}:${frame.data.groupId}`},
- }
- : `/organizations/${organization.slug}/issues/${frame.data.groupId}/`
- }
- >
- {frame.data.groupShortId}
- </Link>
- </CrumbIssueWrapper>
- );
- }
- const CrumbIssueWrapper = styled('div')`
- display: flex;
- align-items: center;
- gap: ${space(0.5)};
- font-size: ${p => p.theme.fontSizeSmall};
- color: ${p => p.theme.subText};
- `;
- const InspectorWrapper = styled('div')`
- font-family: ${p => p.theme.text.familyMono};
- `;
- const CrumbDetails = styled('div')`
- display: flex;
- flex-direction: column;
- overflow: hidden;
- gap: ${space(0.5)};
- `;
- const TitleContainer = styled('div')`
- display: flex;
- justify-content: space-between;
- gap: ${space(1)};
- font-size: ${p => p.theme.fontSizeSmall};
- `;
- const Title = styled('span')`
- ${p => p.theme.overflowEllipsis};
- text-transform: capitalize;
- font-size: ${p => p.theme.fontSizeMedium};
- font-weight: 600;
- color: ${p => p.theme.gray400};
- line-height: ${p => p.theme.text.lineHeightBody};
- `;
- const Description = styled(Tooltip)`
- ${p => p.theme.overflowEllipsis};
- font-size: 0.7rem;
- font-variant-numeric: tabular-nums;
- line-height: ${p => p.theme.text.lineHeightBody};
- color: ${p => p.theme.subText};
- `;
- const CrumbItem = styled(PanelItem)<{isErrorFrame?: boolean}>`
- display: grid;
- grid-template-columns: max-content auto;
- align-items: flex-start;
- gap: ${space(1)};
- width: 100%;
- font-size: ${p => p.theme.fontSizeMedium};
- background: ${p => (p.isErrorFrame ? `${p.theme.red100}` : `transparent`)};
- padding: ${space(1)};
- text-align: left;
- border: none;
- position: relative;
- border-radius: ${p => p.theme.borderRadius};
- &:hover {
- background-color: ${p => p.theme.surface200};
- }
- /* Draw a vertical line behind the breadcrumb icon. The line connects each row together, but is truncated for the first and last items */
- &::after {
- content: '';
- position: absolute;
- left: 19.5px;
- width: 1px;
- background: ${p => p.theme.gray200};
- height: 100%;
- }
- &:first-of-type::after {
- top: ${space(1)};
- bottom: 0;
- }
- &:last-of-type::after {
- top: 0;
- height: ${space(1)};
- }
- &:only-of-type::after {
- height: 0;
- }
- `;
- const CodeContainer = styled('div')`
- max-height: 400px;
- max-width: 100%;
- overflow: auto;
- `;
- export default memo(BreadcrumbItem);
|