Skip to content

Commit

Permalink
feat(replay): Implement accessibility details panel (#60268)
Browse files Browse the repository at this point in the history
Relates to: #55293
  • Loading branch information
ryan953 authored Nov 27, 2023
1 parent 1f6702c commit 5596400
Show file tree
Hide file tree
Showing 11 changed files with 351 additions and 40 deletions.
13 changes: 8 additions & 5 deletions static/app/utils/replays/hooks/useA11yData.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {useMemo} from 'react';

import {useReplayContext} from 'sentry/components/replays/replayContext';
import {useApiQuery} from 'sentry/utils/queryClient';
import hydrateA11yFrame, {RawA11yResponse} from 'sentry/utils/replays/hydrateA11yFrame';
Expand All @@ -13,7 +15,7 @@ export default function useA11yData() {
const startTimestampMs = replayRecord?.started_at.getTime();
const project = projects.find(p => p.id === replayRecord?.project_id);

const {data} = useApiQuery<RawA11yResponse>(
const {data, ...rest} = useApiQuery<RawA11yResponse>(
[
`/projects/${organization.slug}/${project?.slug}/replays/${replayRecord?.id}/accessibility-issues/`,
],
Expand All @@ -23,8 +25,9 @@ export default function useA11yData() {
}
);

if (project && replayRecord && startTimestampMs) {
return data?.data.map(record => hydrateA11yFrame(record));
}
return [];
const hydrated = useMemo(
() => data?.data?.flatMap(record => hydrateA11yFrame(record, startTimestampMs ?? 0)),
[data?.data, startTimestampMs]
);
return {data: hydrated, ...rest};
}
35 changes: 24 additions & 11 deletions static/app/utils/replays/hydrateA11yFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,16 @@ interface A11yIssueElementAlternative {
type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;

export type HydratedA11yFrame = Overwrite<
RawA11yFrame,
Omit<RawA11yFrame, 'elements' | 'help'>,
{
/**
* Alias of `id`
* Rename `help` to conform to ReplayFrame basics.
*/
description: string;
/**
* The specific element instance
*/
element: A11yIssueElement;
/**
* The difference in timestamp and replay.started_at, in millieseconds
*/
Expand All @@ -46,13 +50,22 @@ export type HydratedA11yFrame = Overwrite<
}
>;

export default function hydrateA11yFrame(raw: RawA11yFrame): HydratedA11yFrame {
const timestamp = new Date(raw.timestamp);
return {
...raw,
description: raw.id,
offsetMs: 0,
timestamp,
timestampMs: timestamp.getTime(),
};
export default function hydrateA11yFrame(
raw: RawA11yFrame,
startTimestampMs: number
): HydratedA11yFrame[] {
return raw.elements.map((element): HydratedA11yFrame => {
const timestamp = new Date(raw.timestamp);
const timestampMs = timestamp.getTime();
return {
description: raw.help,
element,
help_url: raw.help_url,
id: raw.id,
impact: raw.impact,
offsetMs: timestampMs - startTimestampMs,
timestamp,
timestampMs,
};
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ const AccessibilityTableCell = forwardRef<HTMLDivElement, Props>(
() => (
<Cell {...columnProps}>
<CodeHighlightCell language="html" hideCopyButton>
{a11yIssue.elements?.[0].element ?? EMPTY_CELL}
{a11yIssue.element.element ?? EMPTY_CELL}
</CodeHighlightCell>
</Cell>
),
Expand Down
118 changes: 118 additions & 0 deletions static/app/views/replays/detail/accessibility/details/components.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {Fragment, ReactNode, useState} from 'react';
import styled from '@emotion/styled';

import {KeyValueTable, KeyValueTableRow} from 'sentry/components/keyValueTable';
import {IconChevron} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';

export const Indent = styled('div')`
padding-left: ${space(4)};
`;

const NotFoundText = styled('span')`
color: ${p => p.theme.subText};
font-size: ${p => p.theme.fontSizeSmall};
`;

export type KeyValueTuple = {
key: string;
value: string | ReactNode;
type?: 'warning' | 'error';
};

export function keyValueTableOrNotFound(data: KeyValueTuple[], notFoundText: string) {
return data.length ? (
<StyledKeyValueTable noMargin>
{data.map(({key, value, type}) => (
<KeyValueTableRow
key={key}
keyName={key}
type={type}
value={<ValueContainer>{value}</ValueContainer>}
/>
))}
</StyledKeyValueTable>
) : (
<Indent>
<NotFoundText>{notFoundText}</NotFoundText>
</Indent>
);
}

const ValueContainer = styled('span')`
overflow: scroll;
`;

const SectionTitle = styled('dt')``;

const SectionTitleExtra = styled('span')`
flex-grow: 1;
text-align: right;
font-weight: normal;
`;

const SectionData = styled('dd')`
font-size: ${p => p.theme.fontSizeExtraSmall};
`;

const ToggleButton = styled('button')`
background: ${p => p.theme.background};
border: 0;
color: ${p => p.theme.headingColor};
font-size: ${p => p.theme.fontSizeSmall};
font-weight: 600;
line-height: ${p => p.theme.text.lineHeightBody};
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
gap: ${space(1)};
padding: ${space(0.5)} ${space(1)};
:hover {
background: ${p => p.theme.backgroundSecondary};
}
`;

export function SectionItem({
children,
title,
titleExtra,
}: {
children: ReactNode;
title: ReactNode;
titleExtra?: ReactNode;
}) {
const [isOpen, setIsOpen] = useState(true);

return (
<Fragment>
<SectionTitle>
<ToggleButton aria-label={t('toggle section')} onClick={() => setIsOpen(!isOpen)}>
<IconChevron direction={isOpen ? 'down' : 'right'} size="xs" />
{title}
{titleExtra ? <SectionTitleExtra>{titleExtra}</SectionTitleExtra> : null}
</ToggleButton>
</SectionTitle>
<SectionData>{isOpen ? children : null}</SectionData>
</Fragment>
);
}

const StyledKeyValueTable = styled(KeyValueTable)`
& > dt {
font-size: ${p => p.theme.fontSizeSmall};
padding-left: ${space(4)};
}
& > dd {
${p => p.theme.overflowEllipsis};
font-size: ${p => p.theme.fontSizeSmall};
display: flex;
justify-content: flex-end;
white-space: normal;
text-align: right;
}
`;
28 changes: 28 additions & 0 deletions static/app/views/replays/detail/accessibility/details/content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import styled from '@emotion/styled';

import type {SectionProps} from 'sentry/views/replays/detail/accessibility/details/sections';
import {
ElementSection,
GeneralSection,
} from 'sentry/views/replays/detail/accessibility/details/sections';
import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';

type Props = SectionProps;

export default function AccessibilityDetailsContent(props: Props) {
return (
<OverflowFluidHeight>
<SectionList>
<ElementSection {...props} />
<GeneralSection {...props} />
</SectionList>
</OverflowFluidHeight>
);
}

const OverflowFluidHeight = styled(FluidHeight)`
overflow: auto;
`;
const SectionList = styled('dl')`
margin: 0;
`;
85 changes: 85 additions & 0 deletions static/app/views/replays/detail/accessibility/details/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {Fragment, MouseEvent} from 'react';
import styled from '@emotion/styled';

import {Button} from 'sentry/components/button';
import Stacked from 'sentry/components/replays/breadcrumbs/stacked';
import {IconClose} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {HydratedA11yFrame} from 'sentry/utils/replays/hydrateA11yFrame';
import {useResizableDrawer} from 'sentry/utils/useResizableDrawer';
import AccessibilityDetailsContent from 'sentry/views/replays/detail/accessibility/details/content';
import SplitDivider from 'sentry/views/replays/detail/layout/splitDivider';

type Props = {
item: null | HydratedA11yFrame;
onClose: () => void;
startTimestampMs: number;
} & Omit<ReturnType<typeof useResizableDrawer>, 'size'>;

function AccessibilityDetails({
isHeld,
item,
onClose,
onDoubleClick,
onMouseDown,
startTimestampMs,
}: Props) {
if (!item) {
return null;
}

return (
<Fragment>
<StyledStacked>
<StyledSplitDivider
data-is-held={isHeld}
data-slide-direction="updown"
onDoubleClick={onDoubleClick}
onMouseDown={onMouseDown}
/>
<CloseButtonWrapper>
<Button
aria-label={t('Hide accessibility details')}
borderless
icon={<IconClose isCircled size="sm" color="subText" />}
onClick={(e: MouseEvent) => {
e.preventDefault();
onClose();
}}
size="zero"
/>
</CloseButtonWrapper>
</StyledStacked>

<AccessibilityDetailsContent item={item} startTimestampMs={startTimestampMs} />
</Fragment>
);
}

const StyledStacked = styled(Stacked)`
position: relative;
border-top: 1px solid ${p => p.theme.border};
border-bottom: 1px solid ${p => p.theme.border};
`;

const CloseButtonWrapper = styled('div')`
position: absolute;
right: 0;
height: 100%;
padding: ${space(1)};
z-index: ${p => p.theme.zIndex.initial};
display: flex;
align-items: center;
`;

const StyledSplitDivider = styled(SplitDivider)`
padding: ${space(0.75)};
:hover,
&[data-is-held='true'] {
z-index: ${p => p.theme.zIndex.initial};
}
`;

export default AccessibilityDetails;
63 changes: 63 additions & 0 deletions static/app/views/replays/detail/accessibility/details/sections.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {MouseEvent} from 'react';
import beautify from 'js-beautify';

import {CodeSnippet} from 'sentry/components/codeSnippet';
import {useReplayContext} from 'sentry/components/replays/replayContext';
import {t} from 'sentry/locale';
import type {HydratedA11yFrame} from 'sentry/utils/replays/hydrateA11yFrame';
import {
keyValueTableOrNotFound,
KeyValueTuple,
SectionItem,
} from 'sentry/views/replays/detail/accessibility/details/components';
import TimestampButton from 'sentry/views/replays/detail/timestampButton';

export type SectionProps = {
item: HydratedA11yFrame;
startTimestampMs: number;
};

export function ElementSection({item}: SectionProps) {
return (
<SectionItem title={t('DOM Element')}>
<CodeSnippet language="html" hideCopyButton>
{beautify.html(item.element.element, {indent_size: 2})}
</CodeSnippet>
</SectionItem>
);
}

export function GeneralSection({item, startTimestampMs}: SectionProps) {
const {setCurrentTime} = useReplayContext();

const data: KeyValueTuple[] = [
{
key: t('Impact'),
value: item.impact,
type: item.impact === 'critical' ? 'warning' : undefined,
},
{key: t('Type'), value: item.id},
{key: t('Help'), value: <a href={item.help_url}>{item.description}</a>},
{key: t('Path'), value: item.element.target.join(' ')},
{
key: t('Timestamp'),
value: (
<TimestampButton
format="mm:ss.SSS"
onClick={(event: MouseEvent) => {
event.stopPropagation();
setCurrentTime(item.offsetMs);
}}
startTimestampMs={startTimestampMs}
timestampMs={item.timestampMs}
/>
),
},
];

return (
<SectionItem title={t('General')}>
{keyValueTableOrNotFound(data, t('Missing details'))}
</SectionItem>
);
}
Loading

0 comments on commit 5596400

Please sign in to comment.